diff --git a/.gitignore b/.gitignore index 586d8d7f..db8e51c2 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,7 @@ android/key.properties # build outputs / release artifacts dist/ *.apk +/apk # Local-only dependency overrides (sibling path linking); never commit. pubspec_overrides.yaml diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/BootReceiver.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/BootReceiver.kt index 28a7cd44..905d906d 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/BootReceiver.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/BootReceiver.kt @@ -29,6 +29,7 @@ class BootReceiver : BroadcastReceiver() { action != "android.intent.action.QUICKBOOT_POWERON") return if (!hasPairedDevice(context)) return + markPendingHeadlessBoot(context) val svcIntent = Intent(context, EdgeTrackingService::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -47,4 +48,12 @@ class BootReceiver : BroadcastReceiver() { val id = prefs.getString("flutter.paired_remote_id", null) return !id.isNullOrEmpty() } + + private fun markPendingHeadlessBoot(context: Context) { + val prefs: SharedPreferences = context.getSharedPreferences( + "openstrap_runtime", + Context.MODE_PRIVATE + ) + prefs.edit().putBoolean("pending_headless_boot", true).apply() + } } diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/MainActivity.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/MainActivity.kt index 5ca60d7d..3f3efedd 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/MainActivity.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/MainActivity.kt @@ -1,5 +1,7 @@ package wtf.openstrap.openstrap_edge +import android.content.Context +import android.os.Bundle import io.flutter.embedding.android.FlutterFragmentActivity /** @@ -17,6 +19,38 @@ import io.flutter.embedding.android.FlutterFragmentActivity * FragmentActivity host. The cached-engine overrides work the same on either base. */ class MainActivity : FlutterFragmentActivity() { + companion object { + @Volatile + var activityAttached: Boolean = false + } + override fun getCachedEngineId(): String = EdgeApplication.ENGINE_ID override fun shouldDestroyEngineWithHost(): Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + activityAttached = true + clearPendingHeadlessBoot() + super.onCreate(savedInstanceState) + } + + override fun onStart() { + super.onStart() + activityAttached = true + clearPendingHeadlessBoot() + } + + override fun onStop() { + activityAttached = false + super.onStop() + } + + private fun clearPendingHeadlessBoot() { + val prefs = applicationContext.getSharedPreferences( + "openstrap_runtime", + Context.MODE_PRIVATE + ) + if (prefs.getBoolean("pending_headless_boot", false)) { + prefs.edit().putBoolean("pending_headless_boot", false).apply() + } + } } diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt index ba46feef..36ab43f0 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt @@ -44,6 +44,18 @@ object NativeChannels { app.stopService(Intent(app, EdgeTrackingService::class.java)) result.success(null) } + "consumeHeadlessBootPending" -> { + val prefs = app.getSharedPreferences( + "openstrap_runtime", + Context.MODE_PRIVATE + ) + val pending = prefs.getBoolean("pending_headless_boot", false) + val eligible = pending && !MainActivity.activityAttached + if (eligible) { + prefs.edit().putBoolean("pending_headless_boot", false).apply() + } + result.success(eligible) + } else -> result.notImplemented() } } diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 4481a534..13f946c2 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -37,7 +37,7 @@ import 'dart:async'; import 'dart:io'; -import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:openstrap_protocol/openstrap_protocol.dart'; @@ -76,6 +76,66 @@ typedef CommitSyncBatchSink = /// trigger now that listening is continuous and there's no discrete sync end. typedef DataStoredSink = void Function(); +@visibleForTesting +int countHistoricalBurstPackets({ + required Map dataPacketCountsByRevision, + int revision16Count = 0, + int revision19Count = 0, + int revision22Count = 0, + int revision25Count = 0, + int revision26Count = 0, +}) { + return dataPacketCountsByRevision.values.fold( + 0, + (sum, count) => sum + count, + ) + + revision16Count + + revision19Count + + revision22Count + + revision25Count + + revision26Count; +} + +@visibleForTesting +int countBurstTrafficPackets({ + required Map dataPacketCountsByRevision, + int revision16Count = 0, + int revision19Count = 0, + int revision22Count = 0, + int revision25Count = 0, + int revision26Count = 0, + int eventCount = 0, + int consoleCount = 0, + int unknownCount = 0, +}) { + return countHistoricalBurstPackets( + dataPacketCountsByRevision: dataPacketCountsByRevision, + revision16Count: revision16Count, + revision19Count: revision19Count, + revision22Count: revision22Count, + revision25Count: revision25Count, + revision26Count: revision26Count, + ) + + eventCount + + consoleCount + + unknownCount; +} + +@visibleForTesting +int nextBurstStablePollStreak({ + required bool queueEmpty, + required int currentCount, + required int previousCount, + required int stableStreak, +}) { + if (!queueEmpty) return 0; + return currentCount == previousCount ? (stableStreak + 1) : 0; +} + +@visibleForTesting +bool shouldPauseMaintenanceTraffic({required bool offloadActive}) => + offloadActive; + /// Fired for every LIVE high-rate frame (0x28/0x2B/0x33). These are EPHEMERAL — /// they are NOT persisted to raw_records (that bloated storage ~50x and stalled /// derivation). The caller routes them to an in-memory sink for the live UI / @@ -91,6 +151,100 @@ class SyncReport { SyncReport(this.records, this.batches, this.complete); } +enum _HpsTerminalKind { + metadataWhileNotSyncing, + success, + timeout, + disconnected, + error, +} + +class _HpsTerminal { + final _HpsTerminalKind kind; + final String? reason; + final int successfulBursts; + final int records; + final int batches; + final String? gapSummary; + + const _HpsTerminal({ + required this.kind, + this.reason, + required this.successfulBursts, + required this.records, + required this.batches, + this.gapSummary, + }); +} + +class _SessionPacketCounts { + final Map dataPacketCountsByRevision; + final int revision16Count; + final int consoleLogPacketCount; + final int unknownRevisionCount; + final int revision19Count; + final int revision22Count; + final int revision25Count; + final int revision26Count; + + const _SessionPacketCounts({ + required this.dataPacketCountsByRevision, + required this.revision16Count, + required this.consoleLogPacketCount, + required this.unknownRevisionCount, + required this.revision19Count, + required this.revision22Count, + required this.revision25Count, + required this.revision26Count, + }); + + static const zero = _SessionPacketCounts( + dataPacketCountsByRevision: {}, + revision16Count: 0, + consoleLogPacketCount: 0, + unknownRevisionCount: 0, + revision19Count: 0, + revision22Count: 0, + revision25Count: 0, + revision26Count: 0, + ); +} + +class _SessionGapSummary { + final int intraBurst; + final int crossBurst; + final int missing; + final int backward; + + const _SessionGapSummary({ + required this.intraBurst, + required this.crossBurst, + required this.missing, + required this.backward, + }); + + static const zero = _SessionGapSummary( + intraBurst: 0, + crossBurst: 0, + missing: 0, + backward: 0, + ); + + bool get isEmpty => + intraBurst == 0 && crossBurst == 0 && missing == 0 && backward == 0; + + @override + String toString() { + if (isEmpty) return 'none'; + final parts = []; + if (intraBurst > 0) parts.add('intraBurst=$intraBurst'); + if (crossBurst > 0) parts.add('crossBurst=$crossBurst'); + if (missing > 0) parts.add('missing=$missing'); + if (backward > 0) parts.add('backward=$backward'); + return '{${parts.join(', ')}}'; + } +} + /// All per-connection resources. A fresh one is built on every connect and torn /// down (every subscription + timer cancelled, characteristics nulled) on every /// disconnect — so nothing bleeds across reconnects. @@ -108,6 +262,7 @@ class _Session { Timer? keepAlive; // 30s: liveness watchdog + battery poll + realtime re-arm Timer? periodicBackfill; // 900s: re-trigger the historical offload Timer? idleWatchdog; // 60s: strap went silent mid-offload + Timer? historicalRetry; // explicit abort→retry settle // Starts false: we are NOT connected until connect() resolves / the OS // connectionState stream reports `connected`. (It was previously initialised // true, which combined with the stream replaying a spurious initial @@ -129,6 +284,8 @@ class _Session { periodicBackfill = null; idleWatchdog?.cancel(); idleWatchdog = null; + historicalRetry?.cancel(); + historicalRetry = null; for (final s in subs) { await s.cancel(); } @@ -167,8 +324,10 @@ class BleEngine { final CommitSyncBatchSink? onCommitBatch; /// Tunable debounce window for [onDataStored]. Default coalesces a burst once the - /// stream goes quiet for ~12s, with a 90s never-quiet floor. + /// stream goes quiet. The debouncer can run in a fast stale mode or a calmer + /// fresh mode depending on [deriveDataStaleness]. final DeriveDebouncer deriveDebouncer; + final Duration Function() deriveDataStaleness; BleEngine({ required this.onRecord, @@ -183,6 +342,7 @@ class BleEngine { this.cursorReader, this.deriveDebouncer = const DeriveDebouncer(), this.isBackgroundDrainer = false, + this.deriveDataStaleness = _defaultDeriveDataStaleness, }); /// True for the headless restore-drain engine (runHeadlessSync). It YIELDS the @@ -190,6 +350,8 @@ class BleEngine { /// foreground app engine leaves this false and always wins. final bool isBackgroundDrainer; + static Duration _defaultDeriveDataStaleness() => const Duration(days: 3650); + /// Optional reader for a persisted cursor value (e.g. counter_hw) so the engine /// can seed its frontier from the durable store on connect — making the stuck/ /// continuation detectors correct on the very first offload after a restart. @@ -217,8 +379,10 @@ class BleEngine { final other = _bandOwner; if (other != null && !identical(other, this)) { if (isBackgroundDrainer) { - _log('band already owned by the foreground session — background drain ' - 'yielding (avoids duplicate ACKs on the same offload).'); + _log( + 'band already owned by the foreground session — background drain ' + 'yielding (avoids duplicate ACKs on the same offload).', + ); return false; } _log('preempting a background drain to take the foreground session.'); @@ -262,6 +426,14 @@ class BleEngine { int _historyRequests = 0; int _historyCompletions = 0; SyncReport? _lastSyncReport; + int _successfulBursts = 0; + _HpsTerminal? _lastHpsTerminal; + _SessionPacketCounts _sessionPacketCounts = _SessionPacketCounts.zero; + _SessionGapSummary _sessionGapSummary = _SessionGapSummary.zero; + DateTime? _highFreqUntil; + String? _highFreqReason; + bool _highFreqModeRequested = false; + final Map _lastSequenceByRevision = {}; int? _strapHistoryOldestTs; int? _strapHistoryNewestTs; @@ -279,6 +451,7 @@ class BleEngine { ClockRef? _clockRef; // strap-RTC ↔ wall correlation (set from GET_CLOCK) /// Latest strap-RTC ↔ wall correlation, or null until GET_CLOCK is answered. ClockRef? get clockRef => _clockRef; + /// SET_CLOCK re-issue attempts THIS connection. setClock() reads the clock /// back, and the GET_CLOCK handler re-issues on drift — so cap the retries or /// a firmware that never latches either payload form would loop forever. @@ -290,8 +463,11 @@ class BleEngine { int _frontierTs = 0; // highest historical rec_ts we've durably persisted int _autoContinueCount = 0; // consecutive auto-continues this connection double _lastBackfillAt = 0; // monotonic-ish secs of the last offload trigger + double? _lastHistoricalSendAt; // last actual SEND_HISTORICAL_DATA wall time int _emptyStreak = 0; // consecutive empty offloads (BackfillPolicy backoff) int _droppedImplausible = 0; // records rejected by the plausibility gate + final Map _historicalVersionCounts = {}; + final Set _historicalOpticalDebugKeys = {}; double _wallSecs() => DateTime.now().millisecondsSinceEpoch / 1000.0; @@ -318,6 +494,52 @@ class BleEngine { void _log(String s) => log?.call(s); + void _logHistoricalOptics(Uint8List inner, R24 r) { + final version = r.histVersion; + final count = (_historicalVersionCounts[version] ?? 0) + 1; + _historicalVersionCounts[version] = count; + + // Keep the log small but deterministic: first three records of each version, + // then version milestones that help confirm which path dominates the drain. + final shouldLogMilestone = + count <= 3 || count == 10 || count == 50 || count == 100; + final key = 'v$version#$count'; + if (!shouldLogMilestone || !_historicalOpticalDebugKeys.add(key)) return; + + if (version == 24 || version == 12) { + _log( + '[SPO2] hist=v$version count=$count base=inner ' + 'whoop4_optical(red@64 ir@66 temp@68 amb@70) ' + 'ts=${r.tsEpoch} red=${r.spo2RedRaw} ir=${r.spo2IrRaw} ' + 'temp=${r.skinTempRaw} amb=${r.ambientRaw} ' + 'ppg_green=${r.ppgGreen} ppg_red_ir=${r.ppgRedIr}', + ); + return; + } + + if (version == 25) { + final view = inner.buffer.asByteData( + inner.offsetInBytes, + inner.lengthInBytes, + ); + final u16s = []; + for (int off = 23; off + 2 <= inner.length && off < 73; off += 2) { + u16s.add(view.getUint16(off, Endian.little)); + } + final first = u16s.take(8).toList(); + final min = u16s.isEmpty ? 0 : u16s.reduce((a, b) => a < b ? a : b); + final max = u16s.isEmpty ? 0 : u16s.reduce((a, b) => a > b ? a : b); + _log( + '[SPO2] hist=v25 count=$count base=inner ' + 'known(unix@7 gravity@69/71/73) ' + 'unknown_optical_region=23..72 ' + 'ts=${r.tsEpoch} g=${r.accelG.map((v) => v.toStringAsFixed(4)).join(",")} ' + 'opt_u16_unique=${u16s.toSet().length} opt_u16_min=$min opt_u16_max=$max ' + 'opt_u16_first8=$first', + ); + } + } + /// Note that records were just persisted; (re)arm the debounced derive trigger. /// Called from the record-store paths. No-op when no [onDataStored] is wired. void _noteStored() { @@ -332,6 +554,7 @@ class BleEngine { hasPending: true, sinceLastRecord: DateTime.now().difference(_lastStored), sinceFirstPending: DateTime.now().difference(fp), + dataStaleness: deriveDataStaleness(), ); if (fire) { _firstPending = null; @@ -362,12 +585,29 @@ class BleEngine { 'buffered_records': _drain?.bufferedRecords ?? 0, 'history_requests': _historyRequests, 'history_completions': _historyCompletions, + 'successful_bursts': _successfulBursts, + 'last_hps_terminal': _lastHpsTerminal?.kind.name, + 'last_hps_reason': _lastHpsTerminal?.reason, + 'last_hps_gap_summary': _lastHpsTerminal?.gapSummary, + 'session_packet_counts_by_revision': + _sessionPacketCounts.dataPacketCountsByRevision, + 'session_revision16_count': _sessionPacketCounts.revision16Count, + 'session_console_count': _sessionPacketCounts.consoleLogPacketCount, + 'session_unknown_count': _sessionPacketCounts.unknownRevisionCount, + 'session_revision19_count': _sessionPacketCounts.revision19Count, + 'session_revision22_count': _sessionPacketCounts.revision22Count, + 'session_revision25_count': _sessionPacketCounts.revision25Count, + 'session_revision26_count': _sessionPacketCounts.revision26Count, + 'session_gap_summary': _sessionGapSummary.toString(), 'last_progress_ms': _drain?.lastProgressMs, 'last_report_records': _lastSyncReport?.records, 'last_report_batches': _lastSyncReport?.batches, 'last_report_complete': _lastSyncReport?.complete, 'strap_history_oldest_ts': _strapHistoryOldestTs, 'strap_history_newest_ts': _strapHistoryNewestTs, + 'high_freq_requested': _highFreqModeRequested, + 'high_freq_reason': _highFreqReason, + 'high_freq_until_ms': _highFreqUntil?.millisecondsSinceEpoch, }; int? get strapHistoryNewestTs => _strapHistoryNewestTs; @@ -578,6 +818,14 @@ class BleEngine { _stuckStrap = StuckStrapDetector(); _autoContinueCount = 0; _lastBackfillAt = 0; + _successfulBursts = 0; + _lastHpsTerminal = null; + _sessionPacketCounts = _SessionPacketCounts.zero; + _sessionGapSummary = _SessionGapSummary.zero; + _highFreqModeRequested = false; + _highFreqReason = null; + _highFreqUntil = null; + _lastSequenceByRevision.clear(); _droppedImplausible = 0; _sessionOldestUnix = null; _sessionNewestUnix = null; @@ -589,7 +837,11 @@ class BleEngine { // Heartbeat: keep the link alive (~10s LINK_VALID). Owned by the session, so a // disconnect cancels it — no zombie timer firing into a dead characteristic. session.heartbeat = Timer.periodic(const Duration(seconds: 10), (_) { - if (session.connected) _send(Cmd.linkValid, const [0x00]); + if (!session.connected || + shouldPauseMaintenanceTraffic(offloadActive: _offloadActive)) { + return; + } + _send(Cmd.linkValid, const [0x00]); }); // Keep-alive (30s): liveness watchdog (bounce a silently-dead link), periodic // battery poll, and realtime re-arm. @@ -647,6 +899,9 @@ class BleEngine { ); return; } + if (shouldPauseMaintenanceTraffic(offloadActive: _offloadActive)) { + return; + } if (_liveEnabled) { _send(Cmd.sendR10R11Realtime, const [0x01]); _send(Cmd.toggleRealtimeHr, const [0x01]); @@ -701,6 +956,12 @@ class BleEngine { }) async { final d = _drain; if (_session?.connected != true || d == null) return; + if (_offloadActive && !d._complete) { + _log( + '[SYNC] refresh($reason) dropped — strap is already transmitting history.', + ); + return; + } d.rearm(); _setOffloadActive(true); if (refreshRange) { @@ -710,8 +971,21 @@ class BleEngine { // has time to emit the range response before we request another drain. await Future.delayed(const Duration(milliseconds: 120)); } + final wait = HistoricalSyncCommandPolicy.waitSeconds( + _lastHistoricalSendAt, + _wallSecs(), + ); + if (wait > 0) { + _log( + '[SYNC] refresh($reason) — waiting ${wait.toStringAsFixed(2)}s ' + 'for the 0x16 floor.', + ); + await Future.delayed(Duration(milliseconds: (wait * 1000).ceil())); + if (_session?.connected != true) return; + } _log('[SYNC] refresh($reason) — sending SEND_HISTORICAL_DATA.'); await _send(Cmd.sendHistoricalData, const [0x00]); + _lastHistoricalSendAt = _wallSecs(); } Future _subscribe( @@ -739,6 +1013,9 @@ class BleEngine { session.connected = false; // A drain in flight must complete (with linkDown) immediately, not run out // its full budget. + if (_offloadActive) { + _setHpsTerminal(_HpsTerminalKind.disconnected, drain: _drain); + } _drain?.onLinkDown(); if (!wasIntentional) { _feedReconnectDetectors(); @@ -819,6 +1096,54 @@ class BleEngine { await _write(frame); } + Future applyHighFreqWakeWindow({ + required bool enabled, + required DateTime? targetWake, + Duration duration = const Duration(minutes: 90), + int intervalSeconds = 60, + String reason = 'wake_window', + }) async { + if (_session?.connected != true) return; + if (!enabled || targetWake == null) { + await _disableHighFreqSync(reason: '$reason:outside_window'); + return; + } + final unchanged = + _highFreqModeRequested && + _highFreqReason == reason && + _highFreqUntil?.millisecondsSinceEpoch == + targetWake.millisecondsSinceEpoch; + if (unchanged) return; + _log( + '[SYNC] HighFreq enter ($reason) — interval=${intervalSeconds}s ' + 'duration=${duration.inSeconds}s until=${targetWake.toIso8601String()}', + ); + await _write( + cmdEnterHighFreqSync( + _seq.nextLive(), + intervalSeconds: intervalSeconds, + durationSeconds: duration.inSeconds, + ), + ); + _highFreqModeRequested = true; + _highFreqReason = reason; + _highFreqUntil = targetWake; + } + + Future _disableHighFreqSync({required String reason}) async { + if (_session?.connected != true || !_highFreqModeRequested) { + _highFreqModeRequested = false; + _highFreqReason = null; + _highFreqUntil = null; + return; + } + _log('[SYNC] HighFreq exit ($reason).'); + await _write(cmdExitHighFreqSync(_seq.nextLive())); + _highFreqModeRequested = false; + _highFreqReason = null; + _highFreqUntil = null; + } + // ── record store sinks (wrap the caller's sinks + arm the derive debounce) ────── // The drain controller persists through these so a stored historical batch (or a // single live record) re-arms the debounced onDataStored trigger. @@ -886,6 +1211,12 @@ class BleEngine { // Fall through to decodeFrame so the UI gets live telemetry (state.liveHr). } if (pt == PacketType.historicalData) { + if (!_offloadActive) { + _setHpsTerminal( + _HpsTerminalKind.metadataWhileNotSyncing, + reason: 'historical_data_while_not_syncing', + ); + } final recType = frame.inner.length > 1 ? frame.inner[1] : -1; final counter = _counterFromInner(frame.inner); // Decode the record FIRST so we can stamp its REAL time onto rec_ts. The @@ -896,6 +1227,7 @@ class BleEngine { if (recType == Record.r24) { final r = parseR24(frame.inner); if (r != null) { + _logHistoricalOptics(frame.inner, r); sample = Sample( tsEpoch: r.tsEpoch, counter: r.counter, @@ -957,11 +1289,17 @@ class BleEngine { 'inner=${_innerHex(frame.inner)}', ); } else if (pt == PacketType.event) { + if (_offloadActive) { + _drain?.onBurstEvent(); + } _log('[EVENT] ${_innerHex(frame.inner)}'); final e = parseEvent(frame.inner); if (e != null) { + _handleEventInfo(e); onEvent?.call(e.eventId, e.tsEpoch, _innerHex(frame.inner)); } + } else if (pt == PacketType.consoleLogs && _offloadActive) { + _drain?.onBurstConsole(); } final decoded = _maybeAugmentDataRange(frame, decodeFrame(frame)); _absorbState(decoded); @@ -1005,6 +1343,7 @@ class BleEngine { if (recType == Record.r24) { final r = parseR24(frame.inner); if (r != null) { + _logHistoricalOptics(frame.inner, r); sample = Sample( tsEpoch: r.tsEpoch, counter: r.counter, @@ -1100,12 +1439,16 @@ class BleEngine { if (ClockPolicy.shouldSetClock(dev, wall)) { if (_clockCorrectTries < 3) { _clockCorrectTries++; - _log('Clock drift over policy — re-issuing SET_CLOCK ' - '(attempt $_clockCorrectTries/3).'); + _log( + 'Clock drift over policy — re-issuing SET_CLOCK ' + '(attempt $_clockCorrectTries/3).', + ); unawaited(setClock()); } else { - _log('Clock still off after 3 SET_CLOCK attempts — giving up; ' - 'firmware may not accept our payload length.'); + _log( + 'Clock still off after 3 SET_CLOCK attempts — giving up; ' + 'firmware may not accept our payload length.', + ); } } else { _clockCorrectTries = 0; // latched — reset for the next drift episode @@ -1153,10 +1496,63 @@ class BleEngine { () { _log( '[SYNC] idle watchdog: strap silent ${kBackfillIdleTimeoutSeconds}s ' - 'mid-offload — abandoning the open chunk (band will re-send).', + 'mid-offload — aborting historical sync and scheduling a retry.', ); _drain?.discardOpenChunk(); - unawaited(_onOffloadFinished(complete: false)); + unawaited(_abortAndRetryHistorical(reason: 'idle_watchdog')); + }, + ); + } + + void _handleEventInfo(EventInfo event) { + switch (event.eventId) { + case EventId.highFreqSyncPrompt: + _log( + '[SYNC] HighFreq prompt received — scheduling a one-shot historical refresh.', + ); + unawaited( + _startHistoricalRefresh( + trigger: BackfillTrigger.strap, + reason: 'high_freq_prompt', + refreshRange: true, + ), + ); + return; + case EventId.highFreqSyncEnabled: + _log('[SYNC] HighFreq sync enabled event received.'); + _highFreqModeRequested = true; + return; + case EventId.highFreqSyncDisabled: + _log('[SYNC] HighFreq sync disabled event received.'); + _highFreqModeRequested = false; + _highFreqReason = null; + _highFreqUntil = null; + return; + } + } + + Future _abortAndRetryHistorical({required String reason}) async { + final session = _session; + if (session == null || !session.connected) return; + session.idleWatchdog?.cancel(); + session.historicalRetry?.cancel(); + _setOffloadActive(false); + _log('[SYNC] abort($reason) — sending ABORT_HISTORICAL.'); + await _send(Cmd.abortHistoricalTransmits, const [0x00]); + session.historicalRetry = Timer( + const Duration(seconds: kHistoricalAbortRetryDelaySeconds), + () { + if (_session != session || !session.connected) return; + _log( + '[SYNC] abort($reason) — retrying historical refresh after settle.', + ); + unawaited( + _startHistoricalRefresh( + trigger: BackfillTrigger.strap, + reason: 'abort_retry:$reason', + refreshRange: true, + ), + ); }, ); } @@ -1170,19 +1566,82 @@ class BleEngine { '${frame.inner.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}', ); if (m.sub == SyncMeta.historyStart) { + final d = _drain; + if (_offloadActive && d != null && d.bufferedRecords > 0) { + _log( + '[SYNC] HistoryStart received during active burst — discarding ' + 'partial open chunk and restarting burst state.', + ); + d.discardOpenChunk(); + } + _session?.historicalRetry?.cancel(); + d?.rearm(); _setOffloadActive(true); return; } if (m.sub == SyncMeta.historyEnd && m.token != null) { final d = _drain; if (d == null) return; + if (!_offloadActive) { + _setHpsTerminal( + _HpsTerminalKind.metadataWhileNotSyncing, + reason: 'history_end_while_not_syncing', + drain: d, + ); + } + await _awaitBurstTrafficSettle(d); + final expected = m.expectedPacketCount; + if (expected != null && !d.validateBurst(expectedPacketCount: expected)) { + _log( + '[SYNC] Burst validation failed ' + '(attempt ${d.consecutiveValidationFailures}): expected=$expected, ' + 'actual=${d.currentBurstPacketCount}, ' + 'historical=${d.currentBurstHistoricalPacketCount}, ' + 'traffic=${d.currentBurstTrafficCount}, ' + 'breakdown=${d.currentBurstBreakdown}', + ); + d.discardOpenChunk(); + final fail = buildHistoryResultFail(_seq.nextSync()); + _log( + '[SYNC] FAIL frame=' + '${fail.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}', + ); + await _write(fail); + await LocalDb.upsertSyncLedgerEntry( + status: 'validation_failed', + lastError: 'burst_packet_mismatch', + metaPatch: { + 'expected_burst_packets': expected, + 'actual_burst_packets': d.currentBurstPacketCount, + 'historical_burst_packets': d.currentBurstHistoricalPacketCount, + 'traffic_burst_packets': d.currentBurstTrafficCount, + 'burst_validation_failures': d.consecutiveValidationFailures, + 'burst_breakdown': d.currentBurstBreakdown, + }, + ); + if (d.consecutiveValidationFailures >= 15) { + _log( + '[SYNC] Burst validation stuck after ' + '${d.consecutiveValidationFailures} failures.', + ); + _setHpsTerminal(_HpsTerminalKind.error, reason: 'stuck', drain: d); + _setOffloadActive(false); + return; + } + unawaited(_abortAndRetryHistorical(reason: 'burst_validation_failed')); + return; + } + _successfulBursts++; + _mergeValidatedBurst(d); final tokenHex = m.token! .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); final r = d.bufferedRecTsRange; _log( '[SYNC] HistoryEnd batch=${m.batchId} records=${d.records} ' - 'token=$tokenHex ' + 'expected=${m.expectedPacketCount} actual=${d.currentBurstPacketCount} ' + 'historical=${d.currentBurstHistoricalPacketCount} ' + 'traffic=${d.currentBurstTrafficCount} token=$tokenHex ' 'recTs=${r == null ? "none" : "${r.$1}..${r.$2}"}', ); // SAFE-TRIM INVARIANT: persist decoded+raw AND the continuation cursor @@ -1191,7 +1650,7 @@ class BleEngine { // re-delivers the chunk. Echo the 8-byte slice the band acks verbatim — // a mangled echo is the "Groundhog Day" re-flood bug. await d.commit(m.token); // raw + samples + strap_trim cursor, atomic - final ack = buildBatchAck(_seq.nextSync(), m.token!); + final ack = buildHistoryResultOk(_seq.nextSync(), m.token!); _log( '[SYNC] ACK frame=' '${ack.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}', @@ -1214,6 +1673,13 @@ class BleEngine { } else if (m.sub == SyncMeta.historyComplete) { final d = _drain; if (d == null) return; + if (!_offloadActive) { + _setHpsTerminal( + _HpsTerminalKind.metadataWhileNotSyncing, + reason: 'history_complete_while_not_syncing', + drain: d, + ); + } // Backlog fully handed over (cursor is now at the live edge). Commit the tail // and KEEP LISTENING — live records continue on the same subscription. We do // NOT ACK a HISTORY_COMPLETE and we do NOT switch modes. @@ -1237,11 +1703,57 @@ class BleEngine { '[SYNC] HistoryComplete — backlog drained (${d.records} records, ' '$_droppedImplausible dropped). Still listening for live records.', ); + _setHpsTerminal(_HpsTerminalKind.success, drain: d); _noteStored(); await _onOffloadFinished(complete: true); } } + Future _awaitBurstTrafficSettle(_DrainController d) async { + const poll = Duration(milliseconds: 60); + const budget = Duration(milliseconds: 720); + const requiredStablePolls = 3; + final deadline = DateTime.now().add(budget); + var previousCount = d.currentBurstPacketCount; + var waitedMs = 0; + var stablePolls = 0; + while (DateTime.now().isBefore(deadline)) { + if (_offloadFrames.isNotEmpty) { + await Future.delayed(poll); + waitedMs += poll.inMilliseconds; + previousCount = d.currentBurstPacketCount; + stablePolls = 0; + continue; + } + await Future.delayed(poll); + waitedMs += poll.inMilliseconds; + final currentCount = d.currentBurstPacketCount; + stablePolls = nextBurstStablePollStreak( + queueEmpty: _offloadFrames.isEmpty, + currentCount: currentCount, + previousCount: previousCount, + stableStreak: stablePolls, + ); + if (_offloadFrames.isEmpty && stablePolls >= requiredStablePolls) { + if (waitedMs > 0) { + _log( + '[SYNC] history-end settle: waited=${waitedMs}ms ' + 'traffic=$currentCount historical=${d.currentBurstHistoricalPacketCount} ' + 'stable_polls=$stablePolls', + ); + } + return; + } + previousCount = currentCount; + } + _log( + '[SYNC] history-end settle timed out at ${waitedMs}ms ' + 'traffic=${d.currentBurstPacketCount} ' + 'historical=${d.currentBurstHistoricalPacketCount} ' + 'stable_polls=$stablePolls', + ); + } + // ── post-offload policy: empty-sync, stuck-strap, auto-continue ────────────── Future _onOffloadFinished({required bool complete}) async { final d = _drain; @@ -1275,7 +1787,6 @@ class BleEngine { stillConnected: _session?.connected == true, strapNewestTs: _sessionNewestUnix, ourFrontierTs: _frontierTs, - rowsPersistedThisSession: d.recordsThisOffload, lastTrimAdvanced: d.lastTrimAdvanced, consecutiveCount: _autoContinueCount, ); @@ -1284,6 +1795,8 @@ class BleEngine { _autoContinueCount++; _log('[SYNC] auto-continue #$_autoContinueCount — more backlog remains.'); await _triggerBackfill(BackfillTrigger.autoContinue); + } else if (!complete && _lastHpsTerminal == null) { + _setHpsTerminal(_HpsTerminalKind.timeout, drain: d); } } @@ -1435,8 +1948,10 @@ class BleEngine { Future setStrapName(String name) async { // Cap at 20 ASCII chars (matches the reference + the GET decoder's length // assumption); the length byte then always stays < 0x20. - final ascii = - name.codeUnits.where((c) => c >= 0x20 && c < 0x7f).take(20).toList(); + final ascii = name.codeUnits + .where((c) => c >= 0x20 && c < 0x7f) + .take(20) + .toList(); final payload = [0x01, ascii.length, ...ascii, 0, 0, 0, 0]; await _send(Cmd.setAdvertisingNameHarvard, payload); _log('SET_ADVERTISING_NAME → "$name"'); @@ -1514,6 +2029,11 @@ class BleEngine { await disableLiveStreams(); } catch (_) {} } + if (_session?.connected == true && _highFreqModeRequested) { + try { + await _disableHighFreqSync(reason: 'intentional_disconnect'); + } catch (_) {} + } await _teardownSession(intentional: true); // Release the single-owner claim ONLY on an intentional disconnect (not on a // link-down we intend to reconnect from) so the band is free for a background @@ -1560,6 +2080,82 @@ class BleEngine { onOffloadState?.call(active); } + void _setHpsTerminal( + _HpsTerminalKind kind, { + String? reason, + _DrainController? drain, + }) { + final d = drain ?? _drain; + _lastHpsTerminal = _HpsTerminal( + kind: kind, + reason: reason, + successfulBursts: _successfulBursts, + records: d?.records ?? 0, + batches: d?.batches ?? 0, + gapSummary: d?.currentBurstBreakdown, + ); + } + + void _mergeValidatedBurst(_DrainController d) { + final burstCounts = d.burstStats.dataPacketCountsByRevision; + final mergedCounts = { + ..._sessionPacketCounts.dataPacketCountsByRevision, + }; + for (final entry in burstCounts.entries) { + mergedCounts[entry.key] = (mergedCounts[entry.key] ?? 0) + entry.value; + } + _sessionPacketCounts = _SessionPacketCounts( + dataPacketCountsByRevision: mergedCounts, + revision16Count: + _sessionPacketCounts.revision16Count + d.burstStats.revision16Count, + consoleLogPacketCount: + _sessionPacketCounts.consoleLogPacketCount + + d.burstStats.consoleCount, + unknownRevisionCount: + _sessionPacketCounts.unknownRevisionCount + d.burstStats.unknownCount, + revision19Count: + _sessionPacketCounts.revision19Count + d.burstStats.revision19Count, + revision22Count: + _sessionPacketCounts.revision22Count + d.burstStats.revision22Count, + revision25Count: + _sessionPacketCounts.revision25Count + d.burstStats.revision25Count, + revision26Count: + _sessionPacketCounts.revision26Count + d.burstStats.revision26Count, + ); + + var crossBurst = _sessionGapSummary.crossBurst; + var missing = _sessionGapSummary.missing + d.burstStats.intraBurstMissing; + var backward = + _sessionGapSummary.backward + d.burstStats.intraBurstBackward; + for (final entry in d.burstStats.sequenceByRevision.entries) { + final rev = entry.key; + final seq = entry.value; + final last = _lastSequenceByRevision[rev]; + if (last != null) { + if (seq.firstSequence > last + 1) { + crossBurst++; + missing += (seq.firstSequence - last) - 1; + } else if (seq.firstSequence <= last) { + backward++; + } + } + final burstLast = seq.lastSequence; + if (burstLast != null) { + final prior = _lastSequenceByRevision[rev]; + if (prior == null || burstLast > prior) { + _lastSequenceByRevision[rev] = burstLast; + } + } + } + _sessionGapSummary = _SessionGapSummary( + intraBurst: + _sessionGapSummary.intraBurst + d.burstStats.intraBurstGapCount, + crossBurst: crossBurst, + missing: missing, + backward: backward, + ); + } + Decoded _maybeAugmentDataRange(Frame frame, Decoded decoded) { if (decoded.kind != 'cmd_response') return decoded; final opcode = decoded.fields['opcode']; @@ -1574,7 +2170,8 @@ class BleEngine { // 2034) — which made `history_newest` garbage, so backlogRemains was // PERMANENTLY true and the offload never recognized completion (it chased a // 2034 target forever). Cap at wall-clock + 1 day (clock skew slack). - final maxPlausible = (DateTime.now().millisecondsSinceEpoch ~/ 1000) + 86400; + final maxPlausible = + (DateTime.now().millisecondsSinceEpoch ~/ 1000) + 86400; final ts = []; for (var off = 0; off + 4 <= payload.length; off++) { final v = u32(payload, off); @@ -1592,8 +2189,8 @@ class BleEngine { /// Per-connection historical-offload helper. Buffers records per ACK boundary and /// flushes them in one transaction (raw-first, BEFORE the HISTORY_END ACK). It is -/// armed for the whole connection (single listening mode) — it never aborts and -/// never switches modes. It just tracks running counts and exposes an +/// armed for the whole connection (single listening mode). It tracks running counts +/// and exposes an /// [awaitComplete] future that resolves when the band signals HISTORY_COMPLETE (or /// the link drops / a safety timeout elapses), so a caller can block until the /// backlog is fully handed over without disturbing the continuous listen. @@ -1612,6 +2209,7 @@ class _DrainController { final List _raws = []; final List _samples = []; + final _BurstStats burstStats = _BurstStats(); int records = 0; // total this connection int recordsThisOffload = 0; // since the last HISTORY_COMPLETE / rearm @@ -1643,17 +2241,24 @@ class _DrainController { } return any ? (lo, hi) : null; } + // Trim-advance tracking for the stuck/continuation detectors: a HISTORY_END // whose 8-byte token differs from the last one means the cursor moved. String? _lastAckedToken; bool lastTrimAdvanced = false; + int consecutiveValidationFailures = 0; bool get _buffering => onCommit != null || onRecordsBatch != null; + int get currentBurstPacketCount => burstStats.totalTrafficPacketCount; + int get currentBurstTrafficCount => burstStats.totalTrafficPacketCount; + int get currentBurstHistoricalPacketCount => burstStats.historicalPacketCount; + String get currentBurstBreakdown => burstStats.breakdownString; void onHistoricalRecord(RawRecord raw, Sample? sample) { records++; recordsThisOffload++; _lastProgressAt = DateTime.now(); + burstStats.onHistoricalData(raw.packetType, raw.counter, sample, raw.hex); if (_buffering) { _raws.add(raw); _samples.add(sample); @@ -1664,6 +2269,22 @@ class _DrainController { void noteBatchAcked() => batches++; + void onBurstEvent() => burstStats.onEvent(); + + void onBurstConsole() => burstStats.onConsole(); + + void onBurstUnknown() => burstStats.onUnknown(); + + bool validateBurst({required int expectedPacketCount}) { + final actual = currentBurstPacketCount; + if (expectedPacketCount == actual) { + consecutiveValidationFailures = 0; + return true; + } + consecutiveValidationFailures++; + return false; + } + /// HISTORY_COMPLETE seen — the backlog has been fully handed over. Marks the /// current offload complete (for any awaiter) WITHOUT ending the listen. void onComplete() { @@ -1677,6 +2298,7 @@ class _DrainController { _complete = false; _linkDown = false; _lastProgressAt = DateTime.now(); + burstStats.reset(); } void onLinkDown() => _linkDown = true; @@ -1762,3 +2384,192 @@ class _DrainController { return done.future; } } + +class _BurstStats { + static const Set _ordinaryHistoricalRevisions = { + 7, + 9, + 10, + 11, + 12, + 18, + 20, + 21, + 24, + }; + + final Map _dataPacketCountsByRevision = {}; + final Map _sequenceByRevision = {}; + int _eventCount = 0; + int _consoleCount = 0; + int _unknownCount = 0; + int _revision16Count = 0; + int _revision19Count = 0; + int _revision22Count = 0; + int _revision25Count = 0; + int _revision26Count = 0; + + Map get dataPacketCountsByRevision => + Map.unmodifiable(_dataPacketCountsByRevision); + Map get sequenceByRevision => + Map.unmodifiable(_sequenceByRevision); + int get eventCount => _eventCount; + int get consoleCount => _consoleCount; + int get unknownCount => _unknownCount; + int get revision16Count => _revision16Count; + int get revision19Count => _revision19Count; + int get revision22Count => _revision22Count; + int get revision25Count => _revision25Count; + int get revision26Count => _revision26Count; + int get intraBurstGapCount => + _sequenceByRevision.values.fold(0, (sum, s) => sum + s.gapCount); + int get intraBurstMissing => + _sequenceByRevision.values.fold(0, (sum, s) => sum + s.missingCount); + int get intraBurstBackward => _sequenceByRevision.values.fold( + 0, + (sum, s) => sum + s.backwardCount, + ); + + int get historicalPacketCount => countHistoricalBurstPackets( + dataPacketCountsByRevision: _dataPacketCountsByRevision, + revision16Count: _revision16Count, + revision19Count: _revision19Count, + revision22Count: _revision22Count, + revision25Count: _revision25Count, + revision26Count: _revision26Count, + ); + + int get totalTrafficPacketCount => countBurstTrafficPackets( + dataPacketCountsByRevision: _dataPacketCountsByRevision, + revision16Count: _revision16Count, + revision19Count: _revision19Count, + revision22Count: _revision22Count, + revision25Count: _revision25Count, + revision26Count: _revision26Count, + eventCount: _eventCount, + consoleCount: _consoleCount, + unknownCount: _unknownCount, + ); + + String get breakdownString { + final parts = []; + final revs = _dataPacketCountsByRevision.keys.toList()..sort(); + for (final rev in revs) { + parts.add('V$rev=${_dataPacketCountsByRevision[rev]}'); + } + if (_revision16Count > 0) parts.add('V16=$_revision16Count'); + if (_revision19Count > 0) parts.add('V19=$_revision19Count'); + if (_revision22Count > 0) parts.add('V22=$_revision22Count'); + if (_revision25Count > 0) parts.add('V25=$_revision25Count'); + if (_revision26Count > 0) parts.add('V26=$_revision26Count'); + if (_eventCount > 0) parts.add('events=$_eventCount'); + if (_consoleCount > 0) parts.add('console=$_consoleCount'); + if (_unknownCount > 0) parts.add('unknown=$_unknownCount'); + final seq = sequenceSummary; + if (seq.isNotEmpty) parts.add(seq); + return '{${parts.join(', ')}}'; + } + + String get sequenceSummary { + final revs = _sequenceByRevision.keys.toList()..sort(); + final parts = []; + for (final rev in revs) { + final s = _sequenceByRevision[rev]!; + if (s.gapCount > 0 || s.backwardCount > 0 || s.missingCount > 0) { + parts.add( + 'seqV$rev(gaps=${s.gapCount}, missing=${s.missingCount}, backward=${s.backwardCount})', + ); + } + } + return parts.join(', '); + } + + void onHistoricalData( + int packetType, + int counter, + Sample? sample, + String rawHex, + ) { + if (packetType != PacketType.historicalData) return; + final inner = hexToBytes(rawHex); + if (inner.length < 2) { + _unknownCount++; + return; + } + final revision = inner[1]; + if (_ordinaryHistoricalRevisions.contains(revision)) { + _dataPacketCountsByRevision[revision] = + (_dataPacketCountsByRevision[revision] ?? 0) + 1; + final seq = _sequenceByRevision.putIfAbsent( + revision, + () => _SequenceState(firstSequence: counter), + ); + seq.observe(counter); + return; + } + switch (revision) { + case 16: + _revision16Count++; + return; + case 19: + _revision19Count++; + return; + case 22: + _revision22Count++; + return; + case 25: + _revision25Count++; + return; + case 26: + _revision26Count++; + return; + default: + _unknownCount++; + return; + } + } + + void onEvent() => _eventCount++; + + void onConsole() => _consoleCount++; + + void onUnknown() => _unknownCount++; + + void reset() { + _dataPacketCountsByRevision.clear(); + _sequenceByRevision.clear(); + _eventCount = 0; + _consoleCount = 0; + _unknownCount = 0; + _revision16Count = 0; + _revision19Count = 0; + _revision22Count = 0; + _revision25Count = 0; + _revision26Count = 0; + } +} + +class _SequenceState { + _SequenceState({required this.firstSequence}); + + final int firstSequence; + int? lastSequence; + int gapCount = 0; + int missingCount = 0; + int backwardCount = 0; + + void observe(int seq) { + final last = lastSequence; + if (last != null) { + if (seq > last + 1) { + gapCount++; + missingCount += (seq - last) - 1; + } else if (seq <= last) { + backwardCount++; + } + } + if (last == null || seq > last) { + lastSequence = seq; + } + } +} diff --git a/lib/ble/ble_state.dart b/lib/ble/ble_state.dart index a2b44824..fe4626aa 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -142,22 +142,23 @@ class SeqAllocator { /// - a generous safety `timeout` elapsed (so a pathological stream can't pin the /// radio forever) /// -/// We DELIBERATELY do NOT stop on a "live edge" (newest record near now) or on an -/// idle gap, and we NEVER send ABORT_HISTORICAL. The band offloads OLDEST-first and -/// only emits HISTORY_COMPLETE once its flash backlog is fully handed over; cutting -/// the offload short (the old liveEdge/idle ABORT) meant we ACKed only part of the -/// backlog, the band's read cursor never reached the end, and on the next connect it -/// re-flooded the same history ("Groundhog Day"). Letting it run to HISTORY_COMPLETE -/// is what advances the cursor durably. Once complete, the SAME subscription keeps -/// delivering live records — there is no mode switch. +/// We DELIBERATELY do NOT stop on a "live edge" (newest record near now). The band +/// offloads OLDEST-first and only emits HISTORY_COMPLETE once its flash backlog is +/// fully handed over; cutting the offload short (the old liveEdge/idle ABORT) meant +/// we ACKed only part of the backlog, the band's read cursor never reached the end, +/// and on the next connect it re-flooded the same history ("Groundhog Day"). +/// +/// The transport now allows one narrow abort path: if an offload goes silent for the +/// full idle watchdog window, the driver abandons the open chunk, sends +/// ABORT_HISTORICAL, waits a short settle delay, and retries. That is a recovery +/// path for a stalled drain, not a normal stop condition. Once complete, the SAME +/// subscription keeps delivering live records — there is no mode switch. enum DrainStop { keepGoing, complete, linkDown, timeout } class DrainStopEvaluator { final Duration timeout; - const DrainStopEvaluator({ - this.timeout = const Duration(seconds: 600), - }); + const DrainStopEvaluator({this.timeout = const Duration(seconds: 600)}); /// Evaluate against the current offload telemetry. All times in seconds. DrainStop evaluate({ @@ -182,12 +183,18 @@ class DrainStopEvaluator { /// the burst into a single pass. Pure + deterministic so it's unit-testable without /// timers — the engine drives it with wall-clock reads. class DeriveDebouncer { - final Duration quietPeriod; - final Duration maxWait; + final Duration staleQuietPeriod; + final Duration staleMaxWait; + final Duration freshQuietPeriod; + final Duration freshMaxWait; + final Duration staleThreshold; const DeriveDebouncer({ - this.quietPeriod = const Duration(seconds: 12), - this.maxWait = const Duration(seconds: 90), + this.staleQuietPeriod = const Duration(seconds: 12), + this.staleMaxWait = const Duration(seconds: 90), + this.freshQuietPeriod = const Duration(minutes: 1), + this.freshMaxWait = const Duration(minutes: 5), + this.staleThreshold = const Duration(minutes: 30), }); /// Should we derive now, given the pending-record bookkeeping? @@ -198,8 +205,12 @@ class DeriveDebouncer { required bool hasPending, required Duration sinceLastRecord, required Duration sinceFirstPending, + required Duration dataStaleness, }) { if (!hasPending) return false; + final staleMode = dataStaleness >= staleThreshold; + final quietPeriod = staleMode ? staleQuietPeriod : freshQuietPeriod; + final maxWait = staleMode ? staleMaxWait : freshMaxWait; if (sinceLastRecord >= quietPeriod) return true; // stream went quiet if (sinceFirstPending >= maxWait) return true; // never-quiet floor return false; diff --git a/lib/cloud/companion_client.dart b/lib/cloud/companion_client.dart index c13294a7..9e6d00be 100644 --- a/lib/cloud/companion_client.dart +++ b/lib/cloud/companion_client.dart @@ -58,8 +58,10 @@ class CompanionClient { 'device_id': deviceId, 'scope': scope, 'granted': granted, - if (termsVersion != null) 'terms_version': termsVersion, - if (userId != null) 'user_id': userId, + ...?termsVersion == null + ? null + : {'terms_version': termsVersion}, + ...?userId == null ? null : {'user_id': userId}, })) .timeout(const Duration(seconds: 10)); return r.statusCode >= 200 && r.statusCode < 300; @@ -85,8 +87,10 @@ class CompanionClient { headers: const {'content-type': 'application/json'}, body: jsonEncode({ 'device_id': deviceId, - if (userId != null) 'user_id': userId, - if (consentVersion != null) 'consent_version': consentVersion, + ...?userId == null ? null : {'user_id': userId}, + ...?consentVersion == null + ? null + : {'consent_version': consentVersion}, 'device': device, 'events': events, })) @@ -113,9 +117,11 @@ class CompanionClient { _u('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/health/upload', { 'device_id': deviceId, 'gz': '1', - if (userId != null) 'user_id': userId, - if (consentVersion != null) 'consent_version': '$consentVersion', - if (appVersion != null) 'app_version': appVersion, + ...?userId == null ? null : {'user_id': userId}, + ...?consentVersion == null + ? null + : {'consent_version': '$consentVersion'}, + ...?appVersion == null ? null : {'app_version': appVersion}, }), headers: const {'content-type': 'application/gzip'}, body: gzBytes, diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 8582cf05..68bc442f 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -2,7 +2,7 @@ // // Current flow (per trigger): // 1. Decide WHICH calendar days need compute (force / pending span / latest -// day + context). +// freshness-critical day). // 2. Build / refresh the first primitive, `sleep_session_candidates`, from a // bounded overlap window only when needed. // 3. Load the exact calendar-day substrate + exact sleep-window substrate for @@ -136,7 +136,7 @@ import 'substrate.dart'; const int kAlgoVersion = 30; /// Raw is kept this many days past derivation, then pruned (derived stays). -const int rawRetentionDays = 14; +const int rawRetentionDays = 3; /// A day stays recomputable for this long after its wake, then FINALIZES (locks) /// — more flash may still drain within this buffer (ARCHITECTURE_V2: ~48 h). @@ -144,7 +144,18 @@ const int _finalizationSec = 48 * 3600; /// How many trailing derived days feed readiness/composite baselines. const int _baselineWindowDays = 28; -const int _lightScopeDays = 3; + +@visibleForTesting +({List days, String reason}) selectLightDeriveDays({ + required Set rawDays, + required List pendingDays, + required String today, +}) { + if (rawDays.contains(today) && pendingDays.contains(today)) { + return (days: [today], reason: 'today-priority'); + } + return (days: [pendingDays.last], reason: 'latest-pending'); +} class _DeriveScope { final bool fullHistory; @@ -316,8 +327,9 @@ class DerivationEngine { Map snapshot() => Map.from(_diag); - /// Run a derivation pass. [heavy]=false runs a bounded light pass (only the - /// most-recent affected day); [heavy]=true sweeps every recomputable day. + /// Run a derivation pass. [heavy]=false runs a bounded light pass over the + /// freshness-critical day: TODAY when raw has reached today, else the latest + /// pending day. [heavy]=true sweeps every recomputable day. /// [force]=true recomputes EVERY non-finalized day regardless of the cursor. /// Re-entrant calls are coalesced. Returns the number of days computed. Future run( @@ -358,9 +370,9 @@ class DerivationEngine { _diag ..['scope_days'] = scope.targetDays.length ..['scope_reason'] = scope.reason; - final dataNowSec = await LocalDb.lastRawRecTs() ?? 0; + final dataNowSec = await LocalDb.lastDecodedRecTs() ?? 0; if (dataNowSec <= 0) { - _log('derive: no raw'); + _log('derive: no decoded data'); return 0; } final finalized = await LocalDb.finalizedDayIds(kAlgoVersion); @@ -376,7 +388,7 @@ class DerivationEngine { if (todoDays.isEmpty) { _log('derive: all days finalized — nothing to do'); if (scope.fullHistory) { - await _pruneOldRaw(todoDays, dataNowSec); + await _pruneOldDecoded(todoDays, dataNowSec); } return 0; } @@ -455,7 +467,7 @@ class DerivationEngine { // 5. Prune raw — never for a day still inside its raw window / un-derived. if (scope.fullHistory) { _diag['stage'] = 'prune'; - await _pruneOldRaw(todoDays, dataNowSec); + await _pruneOldDecoded(todoDays, dataNowSec); } return done; } catch (e, st) { @@ -474,6 +486,100 @@ class DerivationEngine { } } + Future runDays( + Profile profile, + Set days, { + bool force = true, + void Function(String day, int index, int total)? onDayDone, + }) async { + if (days.isEmpty) return 0; + if (_running) return 0; + _running = true; + final startedAt = DateTime.now().millisecondsSinceEpoch; + _diag + ..['running'] = true + ..['stage'] = 'scope' + ..['mode'] = 'selected' + ..['force'] = force + ..['started_at'] = startedAt + ..['finished_at'] = null + ..['duration_ms'] = null + ..['raw_pages'] = 0 + ..['raw_rows'] = 0 + ..['day_raw_pages'] = 0 + ..['day_raw_rows'] = 0 + ..['max_day_raw_pages'] = 0 + ..['max_day_raw_rows'] = 0 + ..['range_from_rec_ts'] = null + ..['range_to_rec_ts'] = null + ..['scope_days'] = days.length + ..['scope_reason'] = 'selected-days' + ..['prepared_days'] = 0 + ..['todo_days'] = 0 + ..['done_days'] = 0 + ..['skipped_days'] = 0 + ..['active_day'] = null + ..['last_error'] = null; + try { + final scope = _scopeForDays(days.toList(), reason: 'selected-days'); + final dataNowSec = await LocalDb.lastDecodedRecTs() ?? 0; + if (dataNowSec <= 0) { + _log('derive selected: no decoded data'); + return 0; + } + final finalized = await LocalDb.finalizedDayIds(kAlgoVersion); + final todoDays = [ + for (final day in scope.targetDays) + if (force || !finalized.contains(day)) day, + ]; + if (todoDays.isEmpty) { + _log('derive selected: all days finalized — nothing to do'); + return 0; + } + _diag['todo_days'] = todoDays.length; + final history = await _BaselineHistoryCache.load(); + var done = 0; + for (var i = 0; i < todoDays.length; i++) { + final dayId = todoDays[i]; + _diag['active_day'] = dayId; + try { + final prepared = await _prepareTargetDay(dayId); + if (prepared != null) { + _diag['prepared_days'] = (_diag['prepared_days'] as int) + 1; + await _derivePreparedDay(prepared, profile, dataNowSec, history); + done++; + _diag['done_days'] = done; + } else { + _diag['skipped_days'] = (_diag['skipped_days'] as int) + 1; + _diag['last_error'] = 'no_bounded_window_payload day=$dayId'; + } + } catch (e) { + _log('derive selected day $dayId FAILED/skipped: $e'); + _diag['skipped_days'] = (_diag['skipped_days'] as int) + 1; + _diag['last_error'] = '$e'; + } + onDayDone?.call(dayId, i + 1, todoDays.length); + } + if (done > 0) { + await _refreshBaselines(history); + await _runCrossDay(profile); + await _runNotifications(); + } + return done; + } catch (e, st) { + _log('derive selected ERROR: $e\n$st'); + return 0; + } finally { + final finishedAt = DateTime.now().millisecondsSinceEpoch; + _diag + ..['running'] = false + ..['stage'] = 'idle' + ..['finished_at'] = finishedAt + ..['duration_ms'] = finishedAt - startedAt; + _running = false; + } + } + static const int _rawDecodeBatchSize = 2000; static const int _maxDayRawRows = 500000; static const int _maxDayRawPages = 300; @@ -604,7 +710,6 @@ class DerivationEngine { _diag ..['range_from_rec_ts'] = fromRecTs ..['range_to_rec_ts'] = toRecTs; - var usedDecoded = false; while (true) { final decodedRows = await LocalDb.decodedOneHzBatchByRecTsRange( limit: _rawDecodeBatchSize, @@ -614,7 +719,6 @@ class DerivationEngine { afterCounter: afterCursor, ); if (decodedRows.isNotEmpty) { - usedDecoded = true; _trackPrepareBatch(decodedRows.length); rangePages += 1; rangeRows += decodedRows.length; @@ -640,33 +744,7 @@ class DerivationEngine { if (decodedRows.length < _rawDecodeBatchSize) break; continue; } - if (usedDecoded) break; - final rows = await LocalDb.rawHexBatchByRecTsRange( - limit: _rawDecodeBatchSize, - fromRecTs: fromRecTs, - toRecTs: toRecTs, - afterRecTs: afterRecTs, - afterRowId: afterCursor, - ); - if (rows.isEmpty) break; - _trackPrepareBatch(rows.length); - rangePages += 1; - rangeRows += rows.length; - _enforcePrepareBudget( - dayId: dayId, - fromRecTs: fromRecTs, - toRecTs: toRecTs, - rangePages: rangePages, - rangeRows: rangeRows, - ); - worker.send({ - 'type': 'page', - 'hexes': [for (final row in rows) row['hex'] as String], - }); - final last = rows.last; - afterRecTs = (last['rec_ts'] as num?)?.toInt() ?? afterRecTs; - afterCursor = (last['rowid'] as num?)?.toInt() ?? afterCursor; - if (rows.length < _rawDecodeBatchSize) break; + break; } worker.send(const {'type': 'finish'}); return result.future; @@ -710,7 +788,7 @@ class DerivationEngine { required bool heavy, required bool force, }) async { - final rawByDay = await LocalDb.rawRecTsMaxByDay(); + final rawByDay = await LocalDb.decodedRecTsMaxByDay(); if (rawByDay.isEmpty) { return const _DeriveScope( fullHistory: true, @@ -736,13 +814,12 @@ class DerivationEngine { return _scopeForDays(pending, reason: 'pending-span'); } - final latest = pending.last; - final latestSec = _localDayLabelToSec(latest); - final scoped = []; - for (var i = _lightScopeDays - 1; i >= 0; i--) { - scoped.add(_localDateLabel(latestSec - i * 86400)); - } - return _scopeForDays(scoped, reason: 'latest+context'); + final light = selectLightDeriveDays( + rawDays: rawByDay.keys.toSet(), + pendingDays: pending, + today: LocalDb.localDayLabelNow(), + ); + return _scopeForDays(light.days, reason: light.reason); } _DeriveScope _scopeForDays( @@ -791,12 +868,12 @@ class DerivationEngine { return 0; } - final rawByDay = await LocalDb.rawRecTsMaxByDay(); + final rawByDay = await LocalDb.decodedRecTsMaxByDay(); if (rawByDay.isEmpty) { - _log('rescan: no raw'); + _log('rescan: no decoded data'); return 0; } - final dataNowSec = await LocalDb.lastRawRecTs() ?? 0; + final dataNowSec = await LocalDb.lastDecodedRecTs() ?? 0; if (dataNowSec <= 0) { _log('rescan: no data edge'); return 0; @@ -807,7 +884,7 @@ class DerivationEngine { if ((_localDayLabelToSec(dayId) + 86400) >= cutoffSec) dayId, ]..sort(); if (todoDays.isEmpty) { - _log('rescan: no recent raw-backed days'); + _log('rescan: no recent decoded-backed days'); await LocalDb.setCursor('baseline_sig', sig); return 0; } @@ -1013,6 +1090,7 @@ class DerivationEngine { final bundle = await Isolate.run( () => deriveDayBundle(withHistory), ).timeout(_perDayTimeout); + _logSpo2Diagnostics(day, input, bundle); // Where this day's sleep window came from (auto / auto_fallback / manual / // confirmed) — drives the Sleep screen's "is this right?" prompt + the @@ -1184,6 +1262,86 @@ class DerivationEngine { ); } + void _logSpo2Diagnostics( + PreparedDerivationDay day, + DayBundleInput input, + Map bundle, + ) { + final red = input.sleepSpo2Red; + final ir = input.sleepSpo2Ir; + final ts = input.sleepTsSec; + if (red.isEmpty || ir.isEmpty || ts.isEmpty) { + _log('[spo2-detect] {"day":"${day.date}","status":"no_sleep_spo2"}'); + return; + } + + int minInt(List xs) => xs.reduce((a, b) => a < b ? a : b); + int maxInt(List xs) => xs.reduce((a, b) => a > b ? a : b); + double meanInt(List xs) => + xs.isEmpty ? 0 : xs.reduce((a, b) => a + b) / xs.length; + + final redNonZero = red.where((v) => v > 0).length; + final irNonZero = ir.where((v) => v > 0).length; + final spo2 = (bundle['spo2'] as Map?)?.cast(); + final ratios = [ + for (var i = 0; i < red.length && i < ir.length; i++) + if (red[i] > 0 && ir[i] > 0) red[i] / ir[i], + ]; + double? meanDouble(List xs) => + xs.isEmpty ? null : xs.reduce((a, b) => a + b) / xs.length; + double? minDouble(List xs) => + xs.isEmpty ? null : xs.reduce((a, b) => a < b ? a : b); + double? maxDouble(List xs) => + xs.isEmpty ? null : xs.reduce((a, b) => a > b ? a : b); + + final payload = { + 'day': day.date, + 'sleep_samples': ts.length, + 'sleep_span_sec': ts.last - ts.first, + 'feature_disabled': spo2?['disabled'] == true, + 'red': { + 'non_zero': redNonZero, + 'zero': red.length - redNonZero, + 'coverage': redNonZero / red.length, + 'unique': red.toSet().length, + 'min': minInt(red), + 'max': maxInt(red), + 'mean': meanInt(red).toStringAsFixed(2), + 'first10': red.take(10).toList(), + }, + 'ir': { + 'non_zero': irNonZero, + 'zero': ir.length - irNonZero, + 'coverage': irNonZero / ir.length, + 'unique': ir.toSet().length, + 'min': minInt(ir), + 'max': maxInt(ir), + 'mean': meanInt(ir).toStringAsFixed(2), + 'first10': ir.take(10).toList(), + }, + 'ratio': { + 'samples': ratios.length, + 'min': minDouble(ratios)?.toStringAsFixed(6), + 'max': maxDouble(ratios)?.toStringAsFixed(6), + 'mean': meanDouble(ratios)?.toStringAsFixed(6), + 'first10': ratios.take(10).map((v) => v.toStringAsFixed(6)).toList(), + }, + 'odi': { + 'disabled': spo2?['disabled'], + 'note': spo2?['note'], + 'value': spo2?['odi_per_hour'], + 'dip_count': spo2?['dip_count'], + 'signal_coverage': spo2?['signal_coverage'], + 'trusted_coverage': spo2?['trusted_coverage'], + 'confidence': spo2?['confidence'], + 'reject_counts': spo2?['reject_counts'], + 'severity_counts': spo2?['severity_counts'], + 'debug': spo2?['debug'], + }, + }; + _log('[spo2-detect] ${jsonEncode(payload)}'); + } + /// Persist a minimal skip marker so a pathological day isn't retried forever. Future _markDaySkipped( String dayId, @@ -1483,7 +1641,7 @@ class DerivationEngine { /// backfill received in one sync must not be pruned just because it landed /// "now". Guard: never prune while any day in [days] is NOT yet derived at the /// current algo version (raw-first). - Future _pruneOldRaw(List dayIds, int dataNowSec) async { + Future _pruneOldDecoded(List dayIds, int dataNowSec) async { final derivedIds = await LocalDb.dayResultIds(kAlgoVersion); final pending = dayIds.where((d) => !derivedIds.contains(d)).toList(); if (pending.isNotEmpty) { @@ -1492,8 +1650,10 @@ class DerivationEngine { } final cutoffSec = dataNowSec - rawRetentionDays * 86400; if (cutoffSec <= 0) return; - final deleted = await LocalDb.pruneRawBeforeRecTs(cutoffSec); - if (deleted > 0) _log('pruned $deleted raw rows with rec_ts < $cutoffSec'); + final deleted = await LocalDb.pruneDecodedBeforeRecTs(cutoffSec); + if (deleted > 0) { + _log('pruned $deleted decoded rows with rec_ts < $cutoffSec'); + } } List _perMinuteMeanWake( @@ -2549,12 +2709,6 @@ class DerivationEngine { return DateTime(d.year, d.month, d.day).millisecondsSinceEpoch ~/ 1000; } - String _localDateLabel(int epochSec) { - final d = DateTime.fromMillisecondsSinceEpoch(epochSec * 1000); - String two(int v) => v.toString().padLeft(2, '0'); - return '${d.year}-${two(d.month)}-${two(d.day)}'; - } - String _skipReasonForError(Object error) { final msg = error.toString(); if (msg.contains('day_prepare_budget_exceeded')) { diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index 0f8b7f87..476e585d 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -312,19 +312,17 @@ Map deriveDayBundle(Map inputJson) { inputs_used: ['resp_rate_series'], ); - // Relative ODI over the SLEEP window's spo2 channels (desaturation screen). + // SpO2 is intentionally disabled for now. We keep carrying the raw red/IR + // channels through the pipeline for observability and future reverse + // engineering, but we do not publish any derived oxygen metric. final odiRed = [for (final v in d.sleepSpo2Red) v.toDouble()]; final odiIr = [for (final v in d.sleepSpo2Ir) v.toDouble()]; final odiTs = [for (final t in d.sleepTsSec) t.toDouble()]; - final odi = - (odiRed.length == odiIr.length && - odiRed.length == odiTs.length && - odiRed.length >= 60) - ? relativeOdi(odiRed, odiIr, odiTs) - : const Metric.absent( - tier: Tier.relative, - inputs_used: ['spo2_red_raw', 'spo2_ir_raw'], - ); + const odi = Metric.absent( + tier: Tier.relative, + inputs_used: ['spo2_red_raw', 'spo2_ir_raw'], + note: 'temporarily disabled pending packet-level reverse engineering', + ); // ── WELLNESS: relative skin-temp deviation (z) vs personal baseline ──────── // STEP 1 — today's RAW mean sleep-window skin-temp ADC. ALWAYS computable when @@ -611,37 +609,31 @@ Map deriveDayBundle(Map inputJson) { // ── SpO₂ (RELATIVE only): overnight oxygen-dip screening from the red/IR ADC // channels. Never absolute %SpO₂; this is a relative overnight signal. - final odiPerHour = odi.present ? odi.value!.odiPerHour : null; - final dipCount = odi.present ? odi.value!.dipCount : null; - final meanDipPct = odi.present ? odi.value!.meanDipPct : null; - final maxDipPct = odi.present ? odi.value!.maxDipPct : null; - final longestDipSec = odi.present ? odi.value!.longestDipSec : null; - final burdenPct = odi.present ? odi.value!.burdenPct : null; - final signalCoverage = odi.present ? odi.value!.signalCoverage : null; - final trustedCoverage = odi.present ? odi.value!.trustedCoverage : null; final rejectCounts = odi.present ? odi.value!.rejectCounts : null; final severityCounts = odi.present ? odi.value!.severityCounts : null; final spo2Block = { - 'value': odiPerHour == null ? '—' : _round(odiPerHour, 2), - 'odi_per_hour': odiPerHour == null ? null : _round(odiPerHour, 2), - 'dip_count': dipCount, - 'mean_dip_pct': meanDipPct == null ? null : _round(meanDipPct, 2), - 'max_dip_pct': maxDipPct == null ? null : _round(maxDipPct, 2), - 'longest_dip_sec': longestDipSec, - 'burden_pct': burdenPct == null ? null : _round(burdenPct, 2), - 'signal_coverage': signalCoverage == null - ? null - : _round(signalCoverage, 4), - 'trusted_coverage': trustedCoverage == null - ? null - : _round(trustedCoverage, 4), + 'disabled': true, + 'value': null, + 'odi_per_hour': null, + 'dip_count': null, + 'analyzed_hours': null, + 'mean_dip_pct': null, + 'max_dip_pct': null, + 'longest_dip_sec': null, + 'burden_pct': null, + 'signal_coverage': null, + 'trusted_coverage': null, 'reject_counts': rejectCounts, 'severity_counts': severityCounts, - 'confidence': odi.present ? _round(odi.confidence, 4) : 0, + 'confidence': 0, 'tier': Tier.relative, 'inputs_used': const ['spo2_red_raw', 'spo2_ir_raw'], - 'note': - 'relative overnight oxygen-dip screen (dips/h); no absolute %SpO₂ from this band', + 'note': 'temporarily disabled pending packet-level reverse engineering', + 'debug': { + 'sleep_samples': odiTs.length, + 'red_non_zero': odiRed.where((v) => v > 0).length, + 'ir_non_zero': odiIr.where((v) => v > 0).length, + }, }; // ── NOCTURNAL detail: sleeping-HR nadir + waking HR. Both computable today @@ -782,12 +774,12 @@ Map deriveDayBundle(Map inputJson) { 'sdnn': hrvT.present ? hrvT.value!.sdnn : null, 'dip_pct': dip.present ? dip.value!.dipPct : null, 'trimp': trimp.present ? trimp.value : null, - 'odi_per_hour': odi.present ? odi.value!.odiPerHour : null, + 'odi_per_hour': null, 'cpc_ratio': cpc.present ? cpc.value!.cpcRatio : null, - // Stress score (0–100) + SI for trends; spo2 relative desaturation index. + // Stress score (0–100) + SI for trends. 'stress': stressScore, 'stress_si': si, - 'spo2': odiPerHour, + 'spo2': null, // Active calories (Keytel) + nocturnal HR detail (nadir / waking HR). 'calories': caloriesKcal == null ? null : _round(caloriesKcal, 0), 'sleeping_hr_nadir': nadir, @@ -802,8 +794,9 @@ Map deriveDayBundle(Map inputJson) { 'lf_hf': lfhf == null ? null : _round(lfhf, 3), 'hrv_cv': hrvCv == null ? null : _round(hrvCv, 1), // 24/7 irregular-rhythm screen flag (1/0) → drives trend + notification. - 'irregular_rhythm_flag': - irregular24h.present ? (irregular24h.value!.flag ? 1.0 : 0.0) : null, + 'irregular_rhythm_flag': irregular24h.present + ? (irregular24h.value!.flag ? 1.0 : 0.0) + : null, // Breathing-rate variability (CV) + Theil-Sen trend slope. 'brv_cv': brv.present ? _round(brv.value!.cv, 4) : null, 'brv_slope': brv.present && brv.value!.trendSlope != null diff --git a/lib/data/db.dart b/lib/data/db.dart index 0b0cb6ed..3bc1ed71 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -1,7 +1,6 @@ // Local raw-first storage (SQLite via sqflite). // // Durable storage layers: -// raw_records — the band's bytes verbatim, keyed by counter. Replay/debug ledger. // decoded_onehz — canonical per-second decoded substrate, deduped by rec_ts. // decoded_rr — sparse RR beats for that substrate, deduped by (rr_ts_ms, beat_index). // samples — legacy header cache kept only for backward-compat fallback. @@ -36,6 +35,8 @@ class LocalDb { } } + static const int _daySec = 86400; + static Future _open() async { final dir = await getDatabasesPath(); final path = p.join(dir, dbName); @@ -74,7 +75,6 @@ class LocalDb { } }, onCreate: (db, version) async { - await _createRaw(db); await _createSamples(db); await _createDecodedStore(db); await db.execute('CREATE INDEX idx_samples_ts ON samples(ts)'); @@ -209,7 +209,9 @@ class LocalDb { await _createBandSignals(db); await _ensureSyncStateSchema(db); await _createLiveCoverage(db); - await db.execute("DELETE FROM metric_series WHERE key = 'active_min'"); + await db.execute( + "DELETE FROM metric_series WHERE key = 'active_min'", + ); await db.execute("DELETE FROM metric_series WHERE key = 'steps'"); } if (oldV < 14) { @@ -231,28 +233,15 @@ class LocalDb { await _createCycleSymptom(db); } if (oldV < 19) { - // v25 features: HRR per session, opt-in auto-workout suggestions, and - // the coach's read-only SQL views over derived data. `hrr_bpm` column + - // suggestions table are additive; views are (re)built in _repairOpenSchema. + await _createDecodedStore(db); + await _backfillDecodedStore(db); + await _dropRawStore(db); await _ensureSessionSchema(db); // adds hrr_bpm await _createWorkoutSuggestions(db); } if (oldV < 20) { - // Manual / confirmed sleep windows (Approach 1 + the fallback's - // "is this right?" confirm). Additive table; survives algo bumps. await _createSleepOverride(db); } - // NOTE (counter-reset recovery): we deliberately do NOT re-decode the raw - // ledger here. onUpgrade runs synchronously inside openDatabase, so a - // full 500k-row re-decode would burn tens of seconds of CPU on the launch - // path and iOS would CPU-watchdog-kill the app → stuck on loading. It is - // also unnecessary: the write path now dedupes by rec_ts with REPLACE - // (see _queueDecodedOneHz), and the derivation coordinator already FALLS - // BACK to decoding raw_records directly for any day-range whose decoded - // rows are absent (see _loadSubstrateRange). So the reboot-quarantined - // days recover on the next re-derive (triggered by the kAlgoVersion bump), - // which runs paged + in a worker isolate AFTER the app is up — never on - // the openDatabase critical path. }, onOpen: (db) async { await _repairOpenSchema(db); @@ -266,7 +255,9 @@ class LocalDb { // existing install. Keep this idempotent and cheap: create missing tables, // indexes, and additive columns the current code assumes are present. await _createSamples(db); - await db.execute('CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples(ts)'); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_samples_ts ON samples(ts)', + ); await _createEvents(db); await _createBandSignals(db); await _createDerived(db); @@ -294,7 +285,6 @@ class LocalDb { } await _createLiveCoverage(db); await _createCycleSymptom(db); - await _ensureRawRecordSchema(db); await _ensureSessionSchema(db); await _ensureSyncStateSchema(db); await _createWorkoutSuggestions(db); @@ -302,6 +292,7 @@ class LocalDb { // Views LAST — they depend on metric_series / day_result / baselines / sessions // / notifications all existing. DROP+CREATE so a shape change takes effect. await _ensureCoachViews(db); + await _dropRawStore(db); } // ── MENSTRUAL SYMPTOM LOG ────────────────────────────────────────────────── @@ -318,22 +309,21 @@ class LocalDb { /// Upsert the symptom set for [date] (empty list clears the row). static Future putCycleSymptoms( - String date, List symptoms, {String? note}) async { + String date, + List symptoms, { + String? note, + }) async { final db = await instance; if (symptoms.isEmpty && (note == null || note.isEmpty)) { await db.delete('cycle_symptom', where: 'date = ?', whereArgs: [date]); return; } - await db.insert( - 'cycle_symptom', - { - 'date': date, - 'symptoms_json': jsonEncode(symptoms), - 'note': note, - 'updated_at': DateTime.now().millisecondsSinceEpoch, - }, - conflictAlgorithm: ConflictAlgorithm.replace, - ); + await db.insert('cycle_symptom', { + 'date': date, + 'symptoms_json': jsonEncode(symptoms), + 'note': note, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }, conflictAlgorithm: ConflictAlgorithm.replace); } /// All symptom rows (newest first): {date, symptoms_json, note}. @@ -391,24 +381,24 @@ class LocalDb { required String source, }) async { final db = await instance; - await db.insert( - 'sleep_override', - { - 'day_id': dayId, - 'onset_ts': onsetTs, - 'offset_ts': offsetTs, - 'source': source, - 'created_at': DateTime.now().millisecondsSinceEpoch ~/ 1000, - }, - conflictAlgorithm: ConflictAlgorithm.replace, - ); + await db.insert('sleep_override', { + 'day_id': dayId, + 'onset_ts': onsetTs, + 'offset_ts': offsetTs, + 'source': source, + 'created_at': DateTime.now().millisecondsSinceEpoch ~/ 1000, + }, conflictAlgorithm: ConflictAlgorithm.replace); } /// The user's sleep window for [dayId], or null if none. static Future?> getSleepOverride(String dayId) async { final db = await instance; - final rows = await db.query('sleep_override', - where: 'day_id = ?', whereArgs: [dayId], limit: 1); + final rows = await db.query( + 'sleep_override', + where: 'day_id = ?', + whereArgs: [dayId], + limit: 1, + ); return rows.isEmpty ? null : rows.first; } @@ -444,36 +434,52 @@ class LocalDb { ) '''); await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_live_coverage_day ON live_coverage(day)'); + 'CREATE INDEX IF NOT EXISTS idx_live_coverage_day ON live_coverage(day)', + ); } /// Record a real 100 Hz step window (device-time seconds) + its step count. static Future addLiveCoverage( - int startTs, int endTs, int steps, String day) async { + int startTs, + int endTs, + int steps, + String day, + ) async { if (steps <= 0 || endTs < startTs) return; final db = await instance; - await db.insert('live_coverage', - {'start_ts': startTs, 'end_ts': endTs, 'steps': steps, 'day': day}); + await db.insert('live_coverage', { + 'start_ts': startTs, + 'end_ts': endTs, + 'steps': steps, + 'day': day, + }); } /// Real (100 Hz) steps attributed to [day]. static Future liveStepsForDay(String day) async { final db = await instance; final r = await db.rawQuery( - 'SELECT COALESCE(SUM(steps),0) s FROM live_coverage WHERE day = ?', [day]); + 'SELECT COALESCE(SUM(steps),0) s FROM live_coverage WHERE day = ?', + [day], + ); return (r.first['s'] as num?)?.toInt() ?? 0; } /// Coverage windows ([startSec, endSec]) overlapping [loSec, hiSec) — used to /// exclude already-counted minutes from the 1 Hz estimate. static Future>> coverageWindowsOverlapping( - int loSec, int hiSec) async { + int loSec, + int hiSec, + ) async { final db = await instance; - final rows = await db.query('live_coverage', - where: 'end_ts >= ? AND start_ts < ?', whereArgs: [loSec, hiSec]); + final rows = await db.query( + 'live_coverage', + where: 'end_ts >= ? AND start_ts < ?', + whereArgs: [loSec, hiSec], + ); return [ for (final r in rows) - [(r['start_ts'] as num).toInt(), (r['end_ts'] as num).toInt()] + [(r['start_ts'] as num).toInt(), (r['end_ts'] as num).toInt()], ]; } @@ -545,14 +551,6 @@ class LocalDb { for (var i = 0; i < raws.length; i++) { final raw = raws[i]; final recTs = _recTsFor(raw); - batch.insert('raw_records', { - 'hex': raw.hex, - 'packet_type': raw.packetType, - 'counter': raw.counter, - 'captured_at': raw.capturedAt, - 'rec_ts': recTs, - 'uploaded': raw.uploaded ? 1 : 0, - }, conflictAlgorithm: ConflictAlgorithm.ignore); final sample = samples[i]; if (sample != null) { batch.insert('samples', { @@ -804,30 +802,8 @@ class LocalDb { await _ensureSyncQuarantineSchema(db); } - static Future _ensureRawRecordSchema(Database db) async { - final cols = await db.rawQuery("PRAGMA table_info(raw_records)"); - final names = { - for (final c in cols) - if (c['name'] is String) c['name'] as String, - }; - if (!names.contains('rec_ts')) { - await _addRecTsColumn(db); - await _backfillRecTs(db); - } - // ALWAYS ensure the rec_ts index exists — NOT only when the column was just - // added. The v8 rebuild (RENAME + recreate) ran with an older _createRaw that - // did not recreate this index, so DBs upgraded through v8 have the rec_ts - // COLUMN but NO index. Every DerivationEngine pass filters/orders - // raw_records by rec_ts, so without it a large ledger (100k+ rows) does a - // full table SCAN + temp-b-tree sort on every derive → the app crawls. - // CREATE INDEX IF NOT EXISTS is a cheap no-op once it's present. - await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_raw_rects ON raw_records(rec_ts)', - ); - await db.execute( - 'CREATE INDEX IF NOT EXISTS idx_raw_unuploaded ' - 'ON raw_records(uploaded, captured_at) WHERE uploaded = 0', - ); + static Future _dropRawStore(Database db) async { + await db.execute('DROP TABLE IF EXISTS raw_records'); } static Future _ensureSessionSchema(Database db) async { @@ -865,21 +841,31 @@ class LocalDb { /// Upsert an auto-detected workout suggestion (id = "$date:$startSec"). static Future putWorkoutSuggestion(Map row) async { final db = await instance; - await db.insert('workout_suggestions', row, - conflictAlgorithm: ConflictAlgorithm.ignore); + await db.insert( + 'workout_suggestions', + row, + conflictAlgorithm: ConflictAlgorithm.ignore, + ); } /// Active (not-yet-dismissed, not-yet-confirmed) suggestions, newest first. static Future>> activeWorkoutSuggestions() async { final db = await instance; - return db.query('workout_suggestions', - where: 'dismissed = 0', orderBy: 'start_ts DESC'); + return db.query( + 'workout_suggestions', + where: 'dismissed = 0', + orderBy: 'start_ts DESC', + ); } static Future dismissWorkoutSuggestion(String id) async { final db = await instance; - await db.update('workout_suggestions', {'dismissed': 1}, - where: 'id = ?', whereArgs: [id]); + await db.update( + 'workout_suggestions', + {'dismissed': 1}, + where: 'id = ?', + whereArgs: [id], + ); } // ── COACH READ-ONLY SQL VIEWS (derived-only) ─────────────────────────────── @@ -1643,21 +1629,14 @@ class LocalDb { static Future insertRecord(RawRecord raw, Sample? sample) async { final db = await instance; - int rawRows = 0; + var inserted = false; await db.transaction((txn) async { final batch = txn.batch(); - rawRows = await txn.insert('raw_records', { - 'hex': raw.hex, - 'packet_type': raw.packetType, - 'counter': raw.counter, - 'captured_at': raw.capturedAt, - 'rec_ts': _recTsFor(raw), - 'uploaded': raw.uploaded ? 1 : 0, - }, conflictAlgorithm: ConflictAlgorithm.ignore); _queueDecodedOneHz(batch, raw, sample); await batch.commit(noResult: true); + inserted = true; }); - return rawRows != 0; + return inserted; } /// Insert many records in ONE transaction. During a historical drain this is @@ -1675,14 +1654,6 @@ class LocalDb { final batch = txn.batch(); for (var i = 0; i < raws.length; i++) { final raw = raws[i]; - batch.insert('raw_records', { - 'hex': raw.hex, - 'packet_type': raw.packetType, - 'counter': raw.counter, - 'captured_at': raw.capturedAt, - 'rec_ts': _recTsFor(raw), - 'uploaded': raw.uploaded ? 1 : 0, - }, conflictAlgorithm: ConflictAlgorithm.ignore); final sample = samples[i]; _queueDecodedOneHz(batch, raw, sample); } @@ -1771,41 +1742,6 @@ class LocalDb { }); } - static Future> unuploadedRaw({int limit = 500}) async { - final db = await instance; - final rows = await db.query( - 'raw_records', - where: 'uploaded = 0', - orderBy: 'captured_at ASC', - limit: limit, - ); - return rows - .map( - (m) => RawRecord( - counter: (m['counter'] as int?) ?? 0, - packetType: (m['packet_type'] as int?) ?? 0, - hex: m['hex'] as String, - capturedAt: m['captured_at'] as int, - recTs: (m['rec_ts'] as int?), - uploaded: false, - ), - ) - .toList(); - } - - /// Once a batch is safely on the server, DELETE the raw blobs locally — we - /// don't need on-device history (the cloud is the system of record). Keeps the - /// device storage tiny: raw_records only ever holds the not-yet-uploaded queue. - static Future markUploaded(List hexes) async { - if (hexes.isEmpty) return; - final db = await instance; - final placeholders = List.filled(hexes.length, '?').join(','); - await db.rawDelete( - 'DELETE FROM raw_records WHERE hex IN ($placeholders)', - hexes, - ); - } - static Future> samplesInRange(int fromTs, int toTs) async { final db = await instance; final decodedRows = await db.query( @@ -1857,60 +1793,31 @@ class LocalDb { static Future> counts() async { final db = await instance; - final raw = + final oneHz = Sqflite.firstIntValue( - await db.rawQuery('SELECT COUNT(*) FROM raw_records'), + await db.rawQuery('SELECT COUNT(*) FROM decoded_onehz'), ) ?? 0; - final pending = + final rr = Sqflite.firstIntValue( - await db.rawQuery( - 'SELECT COUNT(*) FROM raw_records WHERE uploaded = 0', - ), + await db.rawQuery('SELECT COUNT(*) FROM decoded_rr'), ) ?? 0; - return {'raw': raw, 'pending': pending}; - } - - // ── raw read (for the DerivationEngine — main isolate only) ───────────────── - - /// All raw record hexes captured in [fromMs, toMs] (epoch ms = captured_at), - /// oldest first. The engine decodes these via openstrap_protocol off-isolate. - static Future> rawHexInCaptureRange(int fromMs, int toMs) async { - final db = await instance; - final rows = await db.query( - 'raw_records', - columns: ['hex'], - where: 'captured_at >= ? AND captured_at <= ?', - whereArgs: [fromMs, toMs], - orderBy: 'captured_at ASC', - ); - return rows.map((m) => m['hex'] as String).toList(); - } - - /// All raw record hexes whose REAL record time (`rec_ts`, epoch SECONDS) is in - /// [fromSec, toSec], oldest first. This is the day-window read the engine uses so - /// a backfill is split by real day, not by when it was received (captured_at). - static Future> rawHexInRecTsRange(int fromSec, int toSec) async { - final db = await instance; - final rows = await db.query( - 'raw_records', - columns: ['hex'], - where: 'rec_ts >= ? AND rec_ts <= ?', - whereArgs: [fromSec, toSec], - orderBy: 'rec_ts ASC', - ); - return rows.map((m) => m['hex'] as String).toList(); + return { + 'raw': oneHz, + 'pending': 0, + 'decoded_onehz': oneHz, + 'decoded_rr': rr, + }; } - /// `{localDayLabel -> MAX(rec_ts)}` over all raw, grouped by the LOCAL calendar - /// day of the record's real time. The engine compares each day's max rec_ts - /// against its derived cursor to decide what needs (re)derivation. - static Future> rawRecTsMaxByDay() async { + /// `{localDayLabel -> MAX(rec_ts)}` over canonical decoded 1 Hz rows, grouped + /// by the LOCAL calendar day of the record's real time. + static Future> decodedRecTsMaxByDay() async { final db = await instance; final rows = await db.rawQuery( "SELECT strftime('%Y-%m-%d', rec_ts, 'unixepoch', 'localtime') AS d, " - 'MAX(rec_ts) AS mx FROM raw_records GROUP BY d', + 'MAX(rec_ts) AS mx FROM decoded_onehz GROUP BY d', ); final out = {}; for (final r in rows) { @@ -1921,86 +1828,6 @@ class LocalDb { return out; } - /// The newest `captured_at` (epoch ms) across all raw — used to find days with - /// new raw to (re)derive. Null if the store is empty. - static Future latestRawCapturedAt() async { - final db = await instance; - return Sqflite.firstIntValue( - await db.rawQuery('SELECT MAX(captured_at) FROM raw_records'), - ); - } - - /// The oldest `captured_at` (epoch ms) across all raw. Null if empty. - static Future earliestRawCapturedAt() async { - final db = await instance; - return Sqflite.firstIntValue( - await db.rawQuery('SELECT MIN(captured_at) FROM raw_records'), - ); - } - - /// ALL retained raw record hexes, ordered by REAL record time (rec_ts). The - /// engine decodes these ONCE into a single continuous Substrate (substrate.dart). - static Future> allRawHexByRecTs() async { - final db = await instance; - final rows = await db.query( - 'raw_records', - columns: ['hex'], - orderBy: 'rec_ts ASC', - ); - return rows.map((m) => m['hex'] as String).toList(); - } - - /// Cursor for batched decode. Used by the derivation coordinator so the raw - /// ledger never has to cross sqflite as one huge result set. - static Future>> rawHexBatchByRecTs({ - required int limit, - int? afterRecTs, - int? afterRowId, - }) async { - final db = await instance; - if (afterRecTs == null || afterRowId == null) { - return db.rawQuery( - 'SELECT rowid, hex, rec_ts FROM raw_records ' - 'ORDER BY rec_ts ASC, rowid ASC LIMIT ?', - [limit], - ); - } - return db.rawQuery( - 'SELECT rowid, hex, rec_ts FROM raw_records ' - 'WHERE rec_ts > ? OR (rec_ts = ? AND rowid > ?) ' - 'ORDER BY rec_ts ASC, rowid ASC LIMIT ?', - [afterRecTs, afterRecTs, afterRowId, limit], - ); - } - - /// Cursor for batched decode scoped to a REAL record-time window. Used by the - /// derive coordinator so a light/heavy pass can rebuild only the affected raw - /// horizon rather than the full retained ledger. - static Future>> rawHexBatchByRecTsRange({ - required int limit, - required int fromRecTs, - required int toRecTs, - int? afterRecTs, - int? afterRowId, - }) async { - final db = await instance; - if (afterRecTs == null || afterRowId == null) { - return db.rawQuery( - 'SELECT rowid, hex, rec_ts FROM raw_records ' - 'WHERE rec_ts >= ? AND rec_ts <= ? ' - 'ORDER BY rec_ts ASC, rowid ASC LIMIT ?', - [fromRecTs, toRecTs, limit], - ); - } - return db.rawQuery( - 'SELECT rowid, hex, rec_ts FROM raw_records ' - 'WHERE rec_ts >= ? AND rec_ts <= ? ' - 'AND (rec_ts > ? OR (rec_ts = ? AND rowid > ?)) ' - 'ORDER BY rec_ts ASC, rowid ASC LIMIT ?', - [fromRecTs, toRecTs, afterRecTs, afterRecTs, afterRowId, limit], - ); - } - /// Decoded 1 Hz frames in record-time order. This is the preferred derive /// read path: smaller than raw hex, directly queryable, and already split into /// canonical columns. @@ -2171,6 +1998,317 @@ class LocalDb { return dest; } + static Future databaseFileBytes() async { + final dir = await getDatabasesPath(); + final path = p.join(dir, dbName); + final f = File(path); + if (!await f.exists()) return 0; + return await f.length(); + } + + static Future>> dataHistoryDays() async { + final db = await instance; + final rawRows = await db.rawQuery( + "SELECT strftime('%Y-%m-%d', rec_ts, 'unixepoch', 'localtime') AS day_id, " + 'COUNT(*) AS raw_count, ' + 'MIN(rec_ts) AS min_rec_ts, ' + 'MAX(rec_ts) AS max_rec_ts ' + 'FROM decoded_onehz WHERE rec_ts > 0 GROUP BY day_id ORDER BY day_id DESC', + ); + final derivedRows = await db.rawQuery( + 'SELECT r.day_id, r.algo_version, r.computed_at, r.finalized ' + 'FROM day_result r ' + 'JOIN (SELECT day_id, MAX(algo_version) AS v FROM day_result GROUP BY day_id) m ' + ' ON r.day_id = m.day_id AND r.algo_version = m.v ' + 'ORDER BY r.day_id DESC', + ); + final metricRows = await db.rawQuery( + 'SELECT date AS day_id, COUNT(*) AS metric_count ' + 'FROM metric_series GROUP BY date', + ); + final sessionRows = await db.rawQuery( + "SELECT strftime('%Y-%m-%d', start_ts, 'unixepoch', 'localtime') AS day_id, " + 'COUNT(*) AS session_count ' + 'FROM sessions GROUP BY day_id', + ); + final byDay = >{}; + Map ensure(String dayId) => byDay.putIfAbsent( + dayId, + () => { + 'day_id': dayId, + 'raw_count': 0, + 'min_rec_ts': null, + 'max_rec_ts': null, + 'has_derived': false, + 'algo_version': null, + 'computed_at': null, + 'finalized': 0, + 'metric_count': 0, + 'session_count': 0, + }, + ); + for (final row in rawRows) { + final dayId = row['day_id']?.toString(); + if (dayId == null || dayId.isEmpty) continue; + final m = ensure(dayId); + m['raw_count'] = (row['raw_count'] as num?)?.toInt() ?? 0; + m['min_rec_ts'] = (row['min_rec_ts'] as num?)?.toInt(); + m['max_rec_ts'] = (row['max_rec_ts'] as num?)?.toInt(); + } + for (final row in derivedRows) { + final dayId = row['day_id']?.toString(); + if (dayId == null || dayId.isEmpty) continue; + final m = ensure(dayId); + m['has_derived'] = true; + m['algo_version'] = (row['algo_version'] as num?)?.toInt(); + m['computed_at'] = (row['computed_at'] as num?)?.toInt(); + m['finalized'] = (row['finalized'] as num?)?.toInt() ?? 0; + } + for (final row in metricRows) { + final dayId = row['day_id']?.toString(); + if (dayId == null || dayId.isEmpty) continue; + ensure(dayId)['metric_count'] = + (row['metric_count'] as num?)?.toInt() ?? 0; + } + for (final row in sessionRows) { + final dayId = row['day_id']?.toString(); + if (dayId == null || dayId.isEmpty) continue; + ensure(dayId)['session_count'] = + (row['session_count'] as num?)?.toInt() ?? 0; + } + final out = byDay.values.toList() + ..sort( + (a, b) => (b['day_id'] as String).compareTo(a['day_id'] as String), + ); + return out; + } + + static int _localDayStartSec(String dayId) => + DateTime.parse(dayId).millisecondsSinceEpoch ~/ 1000; + + static Future exportDaysDb(Set dayIds) async { + final sorted = dayIds.toList()..sort(); + if (sorted.isEmpty) { + throw ArgumentError('No days selected'); + } + final src = await instance; + final tmp = await getTemporaryDirectory(); + final stamp = DateTime.now().millisecondsSinceEpoch; + final dest = p.join(tmp.path, 'openstrap_days_$stamp.db'); + await deleteDatabase(dest); + final out = await openDatabase( + dest, + onCreate: (db, _) async { + await _createSamples(db); + await _createDecodedStore(db); + await db.execute('CREATE INDEX idx_samples_ts ON samples(ts)'); + await _createEvents(db); + await _createBandSignals(db); + await _createDerived(db); + await _createDayResult(db); + await _createUserTables(db); + await _createSyncState(db); + await _createSyncCursor(db); + await _createComputeState(db); + await _createPrimitiveArtifacts(db); + await _createLiveCoverage(db); + }, + ); + + Future copyRows( + String table, { + String? where, + List whereArgs = const [], + }) async { + final rows = await src.query(table, where: where, whereArgs: whereArgs); + if (rows.isEmpty) return; + await out.transaction((txn) async { + final batch = txn.batch(); + for (final row in rows) { + batch.insert( + table, + Map.from(row), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + }); + } + + Future copyRawRange(int startSec, int endSec) async { + final decoded = await src.query( + 'decoded_onehz', + where: 'rec_ts >= ? AND rec_ts < ?', + whereArgs: [startSec, endSec], + ); + if (decoded.isNotEmpty) { + await out.transaction((txn) async { + final batch = txn.batch(); + for (final row in decoded) { + batch.insert( + 'decoded_onehz', + Map.from(row), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + }); + final counters = [ + for (final row in decoded) + if (row['counter'] != null) row['counter'], + ]; + if (counters.isNotEmpty) { + final placeholders = List.filled(counters.length, '?').join(','); + final rr = await src.rawQuery( + 'SELECT * FROM decoded_rr WHERE counter IN ($placeholders)', + counters, + ); + if (rr.isNotEmpty) { + await out.transaction((txn) async { + final batch = txn.batch(); + for (final row in rr) { + batch.insert( + 'decoded_rr', + Map.from(row), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + }); + } + } + } + await copyRows( + 'samples', + where: 'ts >= ? AND ts < ?', + whereArgs: [startSec, endSec], + ); + await copyRows( + 'events', + where: 'ts >= ? AND ts < ?', + whereArgs: [startSec, endSec], + ); + await copyRows( + 'band_events', + where: 'ts >= ? AND ts < ?', + whereArgs: [startSec, endSec], + ); + await copyRows( + 'band_battery', + where: 'ts >= ? AND ts < ?', + whereArgs: [startSec, endSec], + ); + await copyRows( + 'sessions', + where: 'start_ts >= ? AND start_ts < ?', + whereArgs: [startSec, endSec], + ); + await copyRows( + 'live_coverage', + where: 'end_ts > ? AND start_ts < ?', + whereArgs: [startSec, endSec], + ); + } + + for (final dayId in sorted) { + final startSec = _localDayStartSec(dayId); + final endSec = startSec + _daySec; + await copyRawRange(startSec, endSec); + await copyRows('day_result', where: 'day_id = ?', whereArgs: [dayId]); + await copyRows('metric_series', where: 'date = ?', whereArgs: [dayId]); + await copyRows('journal', where: 'date = ?', whereArgs: [dayId]); + await copyRows('cycle_log', where: 'date = ?', whereArgs: [dayId]); + await copyRows('notifications', where: 'date = ?', whereArgs: [dayId]); + await copyRows( + 'sleep_session_candidates', + where: 'day_id = ?', + whereArgs: [dayId], + ); + await copyRows( + 'wake_day_features', + where: 'day_id = ?', + whereArgs: [dayId], + ); + } + await out.close(); + return dest; + } + + static Future deleteDays(Set dayIds) async { + final sorted = dayIds.toList()..sort(); + if (sorted.isEmpty) return 0; + final db = await instance; + int deleted = 0; + Future deleteByIn( + Transaction txn, + String table, + String column, + List values, + ) async { + final placeholders = List.filled(values.length, '?').join(','); + deleted += await txn.rawDelete( + 'DELETE FROM $table WHERE $column IN ($placeholders)', + values, + ); + } + + await db.transaction((txn) async { + for (final dayId in sorted) { + final startSec = _localDayStartSec(dayId); + final endSec = startSec + _daySec; + deleted += await txn.delete( + 'decoded_rr', + where: + 'counter IN (SELECT counter FROM decoded_onehz WHERE rec_ts >= ? AND rec_ts < ?)', + whereArgs: [startSec, endSec], + ); + deleted += await txn.delete( + 'decoded_onehz', + where: 'rec_ts >= ? AND rec_ts < ?', + whereArgs: [startSec, endSec], + ); + deleted += await txn.delete( + 'samples', + where: 'ts >= ? AND ts < ?', + whereArgs: [startSec, endSec], + ); + deleted += await txn.delete( + 'events', + where: 'ts >= ? AND ts < ?', + whereArgs: [startSec, endSec], + ); + deleted += await txn.delete( + 'band_events', + where: 'ts >= ? AND ts < ?', + whereArgs: [startSec, endSec], + ); + deleted += await txn.delete( + 'band_battery', + where: 'ts >= ? AND ts < ?', + whereArgs: [startSec, endSec], + ); + deleted += await txn.delete( + 'sessions', + where: 'start_ts >= ? AND start_ts < ?', + whereArgs: [startSec, endSec], + ); + deleted += await txn.delete( + 'live_coverage', + where: 'end_ts > ? AND start_ts < ?', + whereArgs: [startSec, endSec], + ); + } + await deleteByIn(txn, 'day_result', 'day_id', sorted); + await deleteByIn(txn, 'metric_series', 'date', sorted); + await deleteByIn(txn, 'journal', 'date', sorted); + await deleteByIn(txn, 'cycle_log', 'date', sorted); + await deleteByIn(txn, 'notifications', 'date', sorted); + await deleteByIn(txn, 'sleep_session_candidates', 'day_id', sorted); + await deleteByIn(txn, 'wake_day_features', 'day_id', sorted); + }); + return deleted; + } + /// Import another device's exported OpenStrap DB ([path], from [exportCopy] + /// share) by MERGING its rows into this one (INSERT-OR-REPLACE). Covers derived /// results, the metric series, user data, and the raw ledger so the receiving @@ -2184,9 +2322,12 @@ class LocalDb { final db = await instance; // Order: independent tables; all use INSERT OR REPLACE so re-import is safe. const tables = [ - 'raw_records', 'samples', 'events', + 'decoded_onehz', + 'decoded_rr', + 'band_events', + 'band_battery', 'day_result', 'metric_series', 'sessions', @@ -2249,22 +2390,12 @@ class LocalDb { final db = await instance; final count = Sqflite.firstIntValue( - await db.rawQuery('SELECT COUNT(*) FROM raw_records'), + await db.rawQuery('SELECT COUNT(*) FROM decoded_onehz'), ) ?? 0; final tsRow = (await db.rawQuery( - 'SELECT MIN(rec_ts) AS lo, MAX(rec_ts) AS hi FROM raw_records WHERE rec_ts > 0', - )).first; - final capRow = (await db.rawQuery( - 'SELECT MIN(captured_at) AS lo, MAX(captured_at) AS hi FROM raw_records', + 'SELECT MIN(rec_ts) AS lo, MAX(rec_ts) AS hi FROM decoded_onehz WHERE rec_ts > 0', )).first; - final typeRows = await db.rawQuery( - 'SELECT packet_type AS t, COUNT(*) AS n FROM raw_records GROUP BY packet_type', - ); - final byType = {}; - for (final r in typeRows) { - byType['${(r['t'] as int?) ?? -1}'] = (r['n'] as int?) ?? 0; - } final decodedOneHz = Sqflite.firstIntValue( await db.rawQuery('SELECT COUNT(*) FROM decoded_onehz'), @@ -2284,15 +2415,95 @@ class LocalDb { 'count': count, 'min_rec_ts': (tsRow['lo'] as num?)?.toInt(), 'max_rec_ts': (tsRow['hi'] as num?)?.toInt(), - 'by_type': byType, - 'min_captured_ms': (capRow['lo'] as num?)?.toInt(), - 'max_captured_ms': (capRow['hi'] as num?)?.toInt(), + 'by_type': const {}, + 'min_captured_ms': null, + 'max_captured_ms': null, 'decoded_onehz': decodedOneHz, 'decoded_rr': decodedRr, 'legacy_samples': legacySamples, }; } + static Future>> tableStorageStats() async { + final db = await instance; + final rows = await db.rawQuery( + "SELECT name FROM sqlite_master " + "WHERE type = 'table' " + "AND name NOT LIKE 'sqlite_%' " + "AND name != 'android_metadata' " + "ORDER BY name ASC", + ); + final out = >[]; + final dbstatAvailable = await _dbstatAvailable(db); + for (final row in rows) { + final name = row['name']?.toString(); + if (name == null || name.isEmpty) continue; + final tableRows = + Sqflite.firstIntValue( + await db.rawQuery('SELECT COUNT(*) FROM $name'), + ) ?? + 0; + final bytes = dbstatAvailable + ? await _tableBytesViaDbstat(db, name) + : await _tableBytesApprox(db, name); + out.add({ + 'table': name, + 'rows': tableRows, + 'bytes': bytes, + 'mb': bytes == null ? null : bytes / (1024 * 1024), + 'approximate': !dbstatAvailable, + }); + } + out.sort((a, b) { + final aa = (a['bytes'] as num?)?.toInt() ?? -1; + final bb = (b['bytes'] as num?)?.toInt() ?? -1; + return bb.compareTo(aa); + }); + return out; + } + + static Future _dbstatAvailable(Database db) async { + try { + await db.rawQuery( + "SELECT SUM(pgsize) AS bytes FROM dbstat WHERE name = 'decoded_onehz'", + ); + return true; + } catch (_) { + return false; + } + } + + static Future _tableBytesViaDbstat(Database db, String table) async { + try { + final row = (await db.rawQuery( + 'SELECT SUM(pgsize) AS bytes FROM dbstat WHERE name = ?', + [table], + )).first; + return (row['bytes'] as num?)?.toInt() ?? 0; + } catch (_) { + return null; + } + } + + static Future _tableBytesApprox(Database db, String table) async { + try { + final cols = await db.rawQuery('PRAGMA table_info($table)'); + if (cols.isEmpty) return 0; + final expr = cols + .map((c) { + final name = c['name']?.toString() ?? ''; + return 'IFNULL(LENGTH($name), 0)'; + }) + .join(' + '); + final row = (await db.rawQuery( + 'SELECT SUM($expr) AS bytes FROM $table', + )).first; + return (row['bytes'] as num?)?.toInt() ?? 0; + } catch (_) { + return null; + } + } + static Future> schemaHealth() async { final db = await instance; Future hasTable(String name) async { @@ -2312,7 +2523,6 @@ class LocalDb { } final requiredTables = [ - 'raw_records', 'samples', 'decoded_onehz', 'decoded_rr', @@ -2341,26 +2551,34 @@ class LocalDb { if (!await hasTable(table)) missingTables.add(table); } - final rawCols = - await hasTable('raw_records') ? await cols('raw_records') : {}; - final sessionCols = - await hasTable('sessions') ? await cols('sessions') : {}; - final syncLedgerCols = - await hasTable('sync_ledger') ? await cols('sync_ledger') : {}; + final sessionCols = await hasTable('sessions') + ? await cols('sessions') + : {}; + final syncLedgerCols = await hasTable('sync_ledger') + ? await cols('sync_ledger') + : {}; final missingColumns = >{}; void expect(String table, Set present, List required) { - final miss = [for (final c in required) if (!present.contains(c)) c]; + final miss = [ + for (final c in required) + if (!present.contains(c)) c, + ]; if (miss.isNotEmpty) missingColumns[table] = miss; } - expect('raw_records', rawCols, ['counter', 'hex', 'captured_at', 'rec_ts']); expect('sessions', sessionCols, ['id', 'start_ts', 'status', 'steps']); - expect('sync_ledger', syncLedgerCols, - ['chunk_id', 'kind', 'status', 'updated_at', 'meta_json']); + expect('sync_ledger', syncLedgerCols, [ + 'chunk_id', + 'kind', + 'status', + 'updated_at', + 'meta_json', + ]); final integrity = await db.rawQuery('PRAGMA integrity_check'); - final integrityOk = integrity.isNotEmpty && integrity.first.values.first == 'ok'; + final integrityOk = + integrity.isNotEmpty && integrity.first.values.first == 'ok'; return { 'ok': missingTables.isEmpty && missingColumns.isEmpty && integrityOk, @@ -2427,7 +2645,7 @@ class LocalDb { int limit, ) async { final rows = await recentDayResults(limit); - final rawByDay = await rawRecTsMaxByDay(); + final rawByDay = await decodedRecTsMaxByDay(); final out = >[]; for (final row in rows) { final payload = row['payload_json'] as String?; @@ -2497,8 +2715,12 @@ class LocalDb { /// Single metric_series value for one (date, key), or null. static Future metricValueOn(String date, String key) async { final db = await instance; - final rows = await db.query('metric_series', - where: 'date = ? AND key = ?', whereArgs: [date, key], limit: 1); + final rows = await db.query( + 'metric_series', + where: 'date = ? AND key = ?', + whereArgs: [date, key], + limit: 1, + ); if (rows.isEmpty) return null; return (rows.first['value'] as num?)?.toDouble(); } @@ -2566,7 +2788,10 @@ class LocalDb { return rows.isEmpty ? null : rows.first; } - static Future putComputeFreshness(String key, String payloadJson) async { + static Future putComputeFreshness( + String key, + String payloadJson, + ) async { final db = await instance; await db.insert('compute_freshness', { 'key': key, @@ -2611,7 +2836,8 @@ class LocalDb { final scalars = ((decoded['scalars'] as Map?) ?? const {}) .cast(); if (latestOvernightDay == null) { - final sleep = ((decoded['sleep'] as Map?)?['accounting'] as Map?)?['value']; + final sleep = + ((decoded['sleep'] as Map?)?['accounting'] as Map?)?['value']; if (sleep is Map && sleep['tst_sec'] != null) { latestOvernightDay = dayId; latestOvernightComputedAt = (row['computed_at'] as num?)?.toInt(); @@ -2622,7 +2848,9 @@ class LocalDb { latestRecoveryDay = dayId; latestRecoveryComputedAt = (row['computed_at'] as num?)?.toInt(); } - if (latestOvernightDay != null && latestRecoveryDay != null && todayRow != null) { + if (latestOvernightDay != null && + latestRecoveryDay != null && + todayRow != null) { break; } } @@ -2630,7 +2858,8 @@ class LocalDb { final wakeComputedAt = (todayWake?['computed_at'] as num?)?.toInt(); final activityReady = todayRow != null || todayWake != null; final overnightReady = latestOvernightDay == today; - final rawReachedToday = latestRawTs != null && _localDayLabelFromEpoch(latestRawTs) == today; + final rawReachedToday = + latestRawTs != null && _localDayLabelFromEpoch(latestRawTs) == today; final activityState = activityReady ? 'ready' : (rawReachedToday ? 'building' : 'missing'); @@ -2641,7 +2870,9 @@ class LocalDb { 'capture', jsonEncode({ 'latest_raw_rec_ts': latestRawTs, - 'latest_raw_day': latestRawTs == null ? null : _localDayLabelFromEpoch(latestRawTs), + 'latest_raw_day': latestRawTs == null + ? null + : _localDayLabelFromEpoch(latestRawTs), 'decoded_onehz': raw['decoded_onehz'], 'decoded_rr': raw['decoded_rr'], }), @@ -2690,10 +2921,7 @@ class LocalDb { final now = DateTime.now().millisecondsSinceEpoch; await db.update( 'compute_jobs', - { - 'state': 'queued', - 'updated_at': now, - }, + {'state': 'queued', 'updated_at': now}, where: 'state = ?', whereArgs: ['running'], ); @@ -2954,8 +3182,12 @@ class LocalDb { /// from the 1 Hz substrate around the session's end during derivation. static Future setSessionHrr(String id, double hrrBpm) async { final db = await instance; - await db.update('sessions', {'hrr_bpm': hrrBpm}, - where: 'id = ?', whereArgs: [id]); + await db.update( + 'sessions', + {'hrr_bpm': hrrBpm}, + where: 'id = ?', + whereArgs: [id], + ); } static Future setSessionType(String id, String type) async { @@ -3011,24 +3243,14 @@ class LocalDb { 0; } - // ── raw pruning (raw-first invariant) ─────────────────────────────────────── + // ── decoded retention ─────────────────────────────────────────────────────── - /// Delete raw_records / decoded substrate / structured band signals / events whose RECORD TIME (epoch - /// seconds) is - /// strictly before [cutoffSec]. Keyed on record time (`rec_ts`/`ts`), NOT - /// receive time (`captured_at`): retention tracks the DATA, so a multi-day - /// flash backfill drained in a single sync is never pruned merely for having - /// just landed. The caller only prunes windows that are FULLY DERIVED — never - /// prune raw for a day that hasn't been derived yet. Returns rows deleted. - static Future pruneRawBeforeRecTs(int cutoffSec) async { + /// Delete decoded substrate / structured band signals / events whose RECORD + /// TIME (epoch seconds) is strictly before [cutoffSec]. + static Future pruneDecodedBeforeRecTs(int cutoffSec) async { final db = await instance; int deleted = 0; await db.transaction((txn) async { - deleted = await txn.delete( - 'raw_records', - where: 'rec_ts < ?', - whereArgs: [cutoffSec], - ); await txn.delete( 'decoded_rr', where: @@ -3048,14 +3270,14 @@ class LocalDb { return deleted; } - /// The DATA EDGE — the timestamp (epoch seconds) of the last record we've - /// actually drained. This, not the wall clock, is "the latest data we have": - /// the band buffers in flash and drains on sync, so this can lag wall-clock - /// time by hours/days. Null when there's no raw yet. - static Future lastRawRecTs() async { + /// The DATA EDGE — the timestamp (epoch seconds) of the last canonical 1 Hz + /// record we've durably stored. + static Future lastDecodedRecTs() async { final db = await instance; return Sqflite.firstIntValue( - await db.rawQuery('SELECT MAX(rec_ts) FROM raw_records WHERE rec_ts > 0'), + await db.rawQuery( + 'SELECT MAX(rec_ts) FROM decoded_onehz WHERE rec_ts > 0', + ), ); } } diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index d21a9844..345d2c92 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -473,7 +473,7 @@ class LocalRepositoryImpl extends LocalRepository { 'daytime_hrv': b['daytime_hrv'], 'nocturnal': _nocturnal(b, baselineRhr: await _seriesMean('rhr')), 'resp': _respObj(b), - 'spo2': _sub(b, 'respiration.odi'), + 'spo2': b['spo2'], // Illness watch (CUSUM/NightSignal) — carries `note` (need_baseline) while // baseline is short, so the card can say "Need N more nights". 'illness': cd?['illness'], @@ -616,10 +616,7 @@ class LocalRepositoryImpl extends LocalRepository { return { 'resp': _respObj(b), 'cvhr': _sub(b, 'respiration.cvhr_apnea'), - 'spo2': _sub( - b, - 'respiration.odi', - ), // relative desaturation screen; never an absolute % + 'spo2': b['spo2'], // relative desaturation screen; never an absolute % 'sleep_window': { 'start': (sleepWin?['onset_ms'] as num?) == null ? null diff --git a/lib/debug/debug_mode.dart b/lib/debug/debug_mode.dart new file mode 100644 index 00000000..9e671024 --- /dev/null +++ b/lib/debug/debug_mode.dart @@ -0,0 +1,4 @@ +const bool advancedDebugMode = bool.fromEnvironment( + 'DEBUG_MODE', + defaultValue: false, +); diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 7c3ba1d8..7c3fdc25 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -48,6 +48,8 @@ import '../notify/notification_relay.dart'; import '../notify/notification_service.dart'; import '../notify/water_buzzer.dart'; import '../sync/edge_tracking.dart'; +import '../sync/band_ownership.dart'; +import '../sync/high_freq_wake_window.dart'; import '../sync/paired_device.dart'; import '../sync/update_service.dart'; import '../telemetry/telemetry_service.dart'; @@ -65,6 +67,7 @@ enum AppRoute { loading, welcome, pairing, profile, shell } class AppState extends ChangeNotifier { late final BleEngine engine; PairedDevice? paired; + BandLease? _foregroundLease; /// SEAM: the screen data layer. Wired to [LocalRepositoryImpl] in the ctor — /// it reads the precomputed derived_day / metric_series rows (ZERO heavy @@ -185,7 +188,8 @@ class AppState extends ChangeNotifier { String get companionUrl => CompanionClient.effectiveBase; /// True when a companion URL is configured (override or build-time). - bool get companionConfigured => CompanionClient.effectiveBase.trim().isNotEmpty; + bool get companionConfigured => + CompanionClient.effectiveBase.trim().isNotEmpty; /// Set (or clear, with '') the runtime companion-URL override. Future setCompanionUrl(String url) async { @@ -348,6 +352,7 @@ class AppState extends ChangeNotifier { String deviceId = ''; bool telemetryConsent = false; bool healthShareConsent = false; + /// Whether the user has been through the enrollment consent screen. Until then /// the toggles default ON there; an install that never saw the screen keeps the /// safe OFF default (we do NOT silently enable for someone who never chose). @@ -416,10 +421,14 @@ class AppState extends ChangeNotifier { await prefs.setBool(_kConsentChosen, true); TelemetryService.instance.enabled = on; notifyListeners(); - unawaited(CompanionClient.postConsent( - deviceId: deviceId, scope: 'telemetry', granted: on, - termsVersion: termsVersion, - )); + unawaited( + CompanionClient.postConsent( + deviceId: deviceId, + scope: 'telemetry', + granted: on, + termsVersion: termsVersion, + ), + ); if (on) unawaited(TelemetryService.instance.flush()); } @@ -431,10 +440,14 @@ class AppState extends ChangeNotifier { await prefs.setBool(_kHealthShareConsent, on); await prefs.setBool(_kConsentChosen, true); notifyListeners(); - unawaited(CompanionClient.postConsent( - deviceId: deviceId, scope: 'health_data', granted: on, - termsVersion: termsVersion, - )); + unawaited( + CompanionClient.postConsent( + deviceId: deviceId, + scope: 'health_data', + granted: on, + termsVersion: termsVersion, + ), + ); } /// Merge + persist local profile fields. Returns the updated map. Replaces the @@ -540,6 +553,7 @@ class AppState extends ChangeNotifier { /// listens; it resets to -1 after consuming. Kept off the ChangeNotifier path so /// a deep-link doesn't repaint the whole tree. final ValueNotifier navRequest = ValueNotifier(-1); + final ValueNotifier insightsRevision = ValueNotifier(0); StreamSubscription? _tapSub; static const Map _routeToTab = { @@ -566,7 +580,7 @@ class AppState extends ChangeNotifier { log: _log, onEvent: _onLiveEvent, onRecordsBatch: LocalDb.insertRecordsBatch, - // RESUMABLE SYNC: atomic commit of raw + samples + continuation cursor + // RESUMABLE SYNC: atomic commit of decoded rows + continuation cursor // before the HISTORY_END ACK, and a reader to seed the offload frontier // from the durable high-water on (re)connect. onCommitBatch: (raws, samples, trimTokenHex) => @@ -574,13 +588,20 @@ class AppState extends ChangeNotifier { cursorReader: LocalDb.getCursorInt, // Debounced compute trigger: with continuous listening there's no discrete // "sync done", so the engine coalesces stored-record bursts and fires this - // once a burst goes quiet. Light pass (newest affected day) — the foreground - // heavy finalize still runs in openSession after the backlog fully drains. + // once a burst goes quiet. Light pass = freshness-first (TODAY when data has + // reached today, else the latest pending day). The foreground heavy finalize + // still runs in openSession after the backlog fully drains. onDataStored: _onDataStored, onOffloadState: (active) => _deriveScheduler.setOffloadActive(active), // LIVE high-rate frames (0x28/0x2B/0x33) are ephemeral — routed here for the - // live UI / spot-check, NEVER persisted to raw_records. + // live UI / spot-check, never persisted. onLiveFrame: _onLiveFrame, + deriveDataStaleness: () { + final ts = _lastRecTs; + if (ts == null || ts <= 0) return const Duration(days: 3650); + final at = DateTime.fromMillisecondsSinceEpoch(ts * 1000); + return DateTime.now().difference(at); + }, ); repo = LocalRepositoryImpl(getProfileMap: () => user); _init(); @@ -593,8 +614,11 @@ class AppState extends ChangeNotifier { void dispose() { _tapSub?.cancel(); _stopBackfillTimer(); + BandOwnership.markForegroundIntent(false); + _releaseForegroundLease(); _deriveScheduler.dispose(); _waterBuzzer.dispose(); + insightsRevision.dispose(); super.dispose(); } @@ -611,10 +635,10 @@ class AppState extends ChangeNotifier { } /// Compute trigger: kick the DerivationEngine after data is persisted. - /// [heavy]=false is the bounded light pass (newest affected day); [heavy]=true is - /// the foreground finalize sweep. Best-effort + non-blocking — never throws into - /// the BLE path. Refreshes the UI when results land so screens re-read the fresh - /// derived rows. + /// [heavy]=false is the bounded light pass (TODAY when raw has reached today, + /// else the latest pending day); [heavy]=true is the foreground finalize + /// sweep. Best-effort + non-blocking — never throws into the BLE path. + /// Refreshes the UI when results land so screens re-read the fresh derived rows. Future _afterDrain({bool heavy = false}) async { try { // Refresh the UI after EACH day so Today/trends fill in as the sweep runs, @@ -630,6 +654,7 @@ class AppState extends ChangeNotifier { }, ); await LocalDb.refreshComputeFreshness(); + _bumpInsightsRevision(); notifyListeners(); // screens re-fetch from the derived store // A heavy finalize is where a freshly-closed sleep window + recovery for a // new physiological day lands — fire the "recovery ready" push off it. @@ -717,15 +742,17 @@ class AppState extends ChangeNotifier { } await prefs.setString(_kLastRecoveryNotifDay, dayId); - await NotificationCenter.instance.emit(NotificationEvent( - dedupeKey: '$dayId:recovery_ready', - category: NotifCategory.recovery, - priority: NotifPriority.normal, - title: 'Your recovery is ready', - body: 'Recovery $score$slept. Tap to see today.', - date: dayId, - route: '/today', - )); + await NotificationCenter.instance.emit( + NotificationEvent( + dedupeKey: '$dayId:recovery_ready', + category: NotifCategory.recovery, + priority: NotifPriority.normal, + title: 'Your recovery is ready', + body: 'Recovery $score$slept. Tap to see today.', + date: dayId, + route: '/today', + ), + ); _log('[notify] recovery-ready fired for $dayId (score=$score)'); } catch (e) { _log('[notify] recovery-ready skipped: $e'); @@ -764,16 +791,21 @@ class AppState extends ChangeNotifier { final prefs = await SharedPreferences.getInstance(); if (prefs.getString(_kLastStepGoalDay) == date) return; // already fired await prefs.setString(_kLastStepGoalDay, date); - await NotificationCenter.instance.emit(NotificationEvent( - dedupeKey: '$date:step_goal', - category: NotifCategory.reminders, - priority: NotifPriority.low, - title: 'Step goal reached', - body: 'You hit about $steps steps — at or above your $goal goal. Nice work.', - date: date, - route: '/today', - )); - } catch (_) {/* best-effort */} + await NotificationCenter.instance.emit( + NotificationEvent( + dedupeKey: '$date:step_goal', + category: NotifCategory.reminders, + priority: NotifPriority.low, + title: 'Step goal reached', + body: + 'You hit about $steps steps — at or above your $goal goal. Nice work.', + date: date, + route: '/today', + ), + ); + } catch (_) { + /* best-effort */ + } } /// Opportunistic "time to move" nudge. HONEST LIMIT: movement is only visible @@ -794,17 +826,22 @@ class AppState extends ChangeNotifier { if (nowMs - lastFired < 2 * 60 * 60 * 1000) return; // rate-limit to /2h await prefs.setInt(_kLastInactivityMs, nowMs); final today = '${now.year}-${now.month}-${now.day}'; - await NotificationCenter.instance.emit(NotificationEvent( - dedupeKey: '$today:move:${nowMs ~/ (2 * 60 * 60 * 1000)}', - category: NotifCategory.reminders, - priority: NotifPriority.low, - title: 'Time to move', - body: "You've been still for a couple of hours — a short walk keeps your " - 'energy and circulation up.', - date: today, - route: '/today', - )); - } catch (_) {/* best-effort */} + await NotificationCenter.instance.emit( + NotificationEvent( + dedupeKey: '$today:move:${nowMs ~/ (2 * 60 * 60 * 1000)}', + category: NotifCategory.reminders, + priority: NotifPriority.low, + title: 'Time to move', + body: + "You've been still for a couple of hours — a short walk keeps your " + 'energy and circulation up.', + date: today, + route: '/today', + ), + ); + } catch (_) { + /* best-effort */ + } } /// True while a user-initiated full re-analysis is running (drives the button's @@ -839,6 +876,7 @@ class AppState extends ChangeNotifier { }, ); await LocalDb.refreshComputeFreshness(); + _bumpInsightsRevision(); dbCounts = await LocalDb.counts(); return n; } catch (e) { @@ -916,6 +954,57 @@ class AppState extends ChangeNotifier { } } + Future reanalyzeDays(Set days) async { + if (days.isEmpty || reanalyzing) return 0; + reanalyzing = true; + final ordered = days.toList()..sort(); + reanalyzeProgress = + 'Analyzing ${ordered.length} day${ordered.length == 1 ? '' : 's'}…'; + notifyListeners(); + try { + final n = await _derive.runDays( + _profile, + days, + force: true, + onDayDone: (day, index, total) async { + reanalyzeProgress = 'Analyzing $index/$total'; + if (index == total || index == 1 || index % 3 == 0) { + dbCounts = await LocalDb.counts(); + notifyListeners(); + } + }, + ); + await LocalDb.refreshComputeFreshness(); + _bumpInsightsRevision(); + dbCounts = await LocalDb.counts(); + return n; + } catch (e) { + _log('[derive] reanalyze selected failed: $e'); + return 0; + } finally { + reanalyzing = false; + reanalyzeProgress = ''; + notifyListeners(); + } + } + + Future>> dataHistoryDays() => + LocalDb.dataHistoryDays(); + + Future dataFileBytes() => LocalDb.databaseFileBytes(); + + Future exportDaysDb(Set dayIds) => + LocalDb.exportDaysDb(dayIds); + + Future deleteDays(Set dayIds) async { + final deleted = await LocalDb.deleteDays(dayIds); + await LocalDb.refreshComputeFreshness(); + dbCounts = await LocalDb.counts(); + lastSynced = await LocalDb.latestSample(); + notifyListeners(); + return deleted; + } + /// Debounced "new data stored" callback from the engine (continuous listening has /// no discrete sync end). The engine already coalesced the burst; we run a single /// LIGHT derive over the affected day(s) and refresh DB counts for the UI. @@ -940,7 +1029,7 @@ class AppState extends ChangeNotifier { await _loadProfile(); await _deriveScheduler.init(); lastSynced = await LocalDb.latestSample(); - _lastRecTs = await LocalDb.lastRawRecTs() ?? lastSynced?.tsEpoch; + _lastRecTs = await LocalDb.lastDecodedRecTs() ?? lastSynced?.tsEpoch; dbCounts = await LocalDb.counts(); await LocalDb.refreshComputeFreshness(); _savedAlarm = (await SharedPreferences.getInstance()).getInt('alarm_epoch'); @@ -954,7 +1043,9 @@ class AppState extends ChangeNotifier { // Companion (anonymous telemetry + health-data contribution) — best-effort, // OFF the critical path so it can never block/break boot. Guarded internally. unawaited(_initCompanion()); - unawaited(armWaterReminder()); // arm the hydration strap-buzz (timers don't persist) + unawaited( + armWaterReminder(), + ); // arm the hydration strap-buzz (timers don't persist) // App status (OTA pointer + admin alert banner) — best-effort, non-blocking. unawaited(_loadAppStatus()); // Register the recurring wall-clock nudges as real OS-scheduled notifications @@ -981,9 +1072,13 @@ class AppState extends ChangeNotifier { final b = v is Map ? (v['bedtime_min_of_day'] as num?) : null; bedtimeMin = b?.toDouble(); } - } catch (_) {/* fall back to default bedtime */} - await NotificationCenter.instance - .scheduleStandingReminders(prefs, bedtimeMinOfDay: bedtimeMin); + } catch (_) { + /* fall back to default bedtime */ + } + await NotificationCenter.instance.scheduleStandingReminders( + prefs, + bedtimeMinOfDay: bedtimeMin, + ); } catch (e) { _log('[notify] schedule reminders skipped: $e'); } @@ -994,7 +1089,10 @@ class AppState extends ChangeNotifier { FileLog.write(line); logLines.insert(0, line); if (logLines.length > 200) logLines.removeLast(); - notifyListeners(); + } + + void _bumpInsightsRevision() { + insightsRevision.value = insightsRevision.value + 1; } /// Called when the app goes to the background. @@ -1112,7 +1210,9 @@ class AppState extends ChangeNotifier { int _liveEnmoN = 0; bool _imuStreamSeen = false; // prefer the 0x33 IMU stream once it appears static const int _minuteSamples = 6000; // 60 s @ 100 Hz — calibration chunk - int _lastMovementMs = 0; // wall-clock of the last live frame showing real motion + int _lastMovementMs = + 0; // wall-clock of the last live frame showing real motion + int _lastLiveUiNotifyMs = 0; // DEVICE-time window (epoch sec) the live pedometer covered this session — so // the 1 Hz estimate can EXCLUDE these minutes (100 Hz real count wins). int? _liveCoverStartTs; @@ -1166,7 +1266,11 @@ class AppState extends ChangeNotifier { _magMin.removeRange(0, _minuteSamples); _committedRaw += ana.pedometer(minute); } - notifyListeners(); // live readout re-counts the partial minute on read + final nowMs = DateTime.now().millisecondsSinceEpoch; + if (nowMs - _lastLiveUiNotifyMs >= 1000) { + _lastLiveUiNotifyMs = nowMs; + notifyListeners(); // live readout re-counts the partial minute on read + } } /// Reset the live step counter for a fresh connected session. @@ -1175,6 +1279,7 @@ class AppState extends ChangeNotifier { _committedRaw = 0; _liveSamples = 0; _liveEnmoSum = 0; + _lastLiveUiNotifyMs = 0; _liveEnmoN = 0; _imuStreamSeen = false; _liveCoverStartTs = null; @@ -1196,7 +1301,8 @@ class AppState extends ChangeNotifier { // 100 Hz always wins and a minute is never counted twice. if (steps > 0 && coverStart != null && coverEnd >= coverStart) { final d = DateTime.fromMillisecondsSinceEpoch(coverStart * 1000); - final day = '${d.year.toString().padLeft(4, '0')}-' + final day = + '${d.year.toString().padLeft(4, '0')}-' '${d.month.toString().padLeft(2, '0')}-' '${d.day.toString().padLeft(2, '0')}'; unawaited(LocalDb.addLiveCoverage(coverStart, coverEnd, steps, day)); @@ -1212,8 +1318,10 @@ class AppState extends ChangeNotifier { final next = ana.calibrateCadence(prior, result, enmo); if (next != null && !identical(next, prior)) { await LocalDb.putStepCalibration(next); - _log('[steps] cadence calibrated → ' - '${next.cadenceSpm.toStringAsFixed(0)} spm (n=${next.n})'); + _log( + '[steps] cadence calibrated → ' + '${next.cadenceSpm.toStringAsFixed(0)} spm (n=${next.n})', + ); } } catch (e) { _log('[steps] calibration skipped: $e'); @@ -1280,6 +1388,8 @@ class AppState extends ChangeNotifier { // OS will relaunch us when it returns. if (_background) unawaited(_armRecovery()); _reconnect(); + } else { + _releaseForegroundLease(); } } _prevConn = s.connection; @@ -1302,6 +1412,7 @@ class AppState extends ChangeNotifier { if (!_keepAlive || paired == null || busy || _reconnecting) return; if (!engine.isConnected) return; try { + await _refreshHighFreqWakeWindow(); _log('Periodic history refresh — requesting another offload.'); final report = await _runSyncBurst(kickFirst: true); _log( @@ -1329,7 +1440,7 @@ class AppState extends ChangeNotifier { }) async { var last = SyncReport(0, 0, false); for (var i = 0; i < maxSessions && engine.isConnected; i++) { - final frontierBefore = await LocalDb.lastRawRecTs(); + final frontierBefore = await LocalDb.lastDecodedRecTs(); if (kickFirst || i > 0) { await engine.requestHistorySync(); } @@ -1337,7 +1448,7 @@ class AppState extends ChangeNotifier { final report = await engine.runSync( timeout: const Duration(seconds: 180), ); - final frontierAfter = await LocalDb.lastRawRecTs(); + final frontierAfter = await LocalDb.lastDecodedRecTs(); final strapNewest = engine.strapHistoryNewestTs; final frontierAdvanced = frontierAfter != null && @@ -1363,6 +1474,10 @@ class AppState extends ChangeNotifier { _log('Backfill stop — no batch ACKs; trim did not advance.'); break; } + if (report.complete && !backlogRemains) { + _log('Backfill stop — history complete acknowledged by strap.'); + break; + } if (!frontierAdvanced && !backlogRemains) { // Frontier didn't advance AND the strap reports nothing newer than what // we already hold → genuinely nothing more to pull (or a pure re-send). @@ -1434,6 +1549,7 @@ class AppState extends ChangeNotifier { Future unpair() async { _keepAlive = false; + BandOwnership.markForegroundIntent(false); _stopBackfillTimer(); IosBleRestore.foregroundActive = false; await EdgeTracking.stop(); @@ -1442,6 +1558,7 @@ class AppState extends ChangeNotifier { // re-establishes iOS-26 relaunch eligibility. No-op on Android / iOS < 18. await AccessorySetup.removeAll(); await engine.disconnect(); + _releaseForegroundLease(); await PairedDevice.clear(); paired = null; notifyListeners(); @@ -1492,6 +1609,8 @@ class AppState extends ChangeNotifier { // ── session: drain history, go live, stay connected ────────────────────────── Future openSession() async { if (busy || paired == null) return; + BandOwnership.markForegroundIntent(true); + _log('[OWNERSHIP] foreground intent on (${BandOwnership.debugState})'); // Returning to the foreground with the connection still alive (kept during // background): don't tear it down and reconnect — just reclaim ownership. final wasBackground = _background; @@ -1542,30 +1661,25 @@ class AppState extends ChangeNotifier { IosBleRestore.arm(paired!.remoteId); _log('===== SESSION START ===== raw=${dbCounts['raw']}'); try { + await _ensureForegroundLease(); // connect() now subscribes → SET_CLOCK → INIT, so the historical offload is - // ALREADY streaming the moment this returns. We just enable live streams (so - // the band also emits live R10/R11) and poll device info; the offload keeps - // running on the same subscription with no mode flip. + // ALREADY streaming the moment this returns. + // + // Important: do NOT send any other foreground commands while the initial + // historical burst is still in flight. Live-stream toggles and info polls + // (battery/name/alarm/high-frequency wake config) add side traffic that can + // perturb the burst packet count and cause near-miss HISTORY_END failures. if (!await engine.connectToRemoteId(paired!.remoteId)) { lastError = 'Could not reach your band. Is it nearby and free ' '(official WHOOP app force-quit)?'; return; } - await engine.getBattery(); - await engine.getStrapName(); // populate strap name for the Profile UI - // Alarm is displayed from the locally-set/persisted value (authoritative); - // the GET_ALARM readback is parked (unconfirmed format) — see ble_engine. - _log('Listening (history first, live after catch-up).'); + await _refreshHighFreqWakeWindow(); // Drain the backlog BEFORE enabling the high-rate live flood. A fresh connect - // after a long gap can hold hours of flash (observed: an overnight ~11h block - // that re-drains from the bottom on every offload until it fully lands). The - // R10/R11 + IMU + optical flood competes with the historical offload for the - // radio and stalls it (60s mid-offload silences → abandon/re-send), so the - // big first drain never finishes and "last data" appears frozen. Dedicate the - // link to the offload first, exactly as the reconnect path already does, then - // turn on live streams once we've caught up. - // + // after a long gap can hold hours of flash. R10/R11 + IMU + optical live + // traffic competes with historical offload traffic and can stall it, so the + // initial catch-up must own the link first. // Blocks until the band's backlog is fully handed over (HISTORY_COMPLETE) — // does NOT abort or end the listen. Per-batch derives already fired via the // debounced onDataStored; once the WHOLE backlog has landed we run the heavy @@ -1575,8 +1689,13 @@ class AppState extends ChangeNotifier { 'Backlog drained: ${report.records} records in ${report.batches} ' 'batches (${report.complete ? "complete" : "stopped early"}).', ); + await _refreshHighFreqWakeWindow(); await engine.enableLiveStreams(); _resetLivePedometer(); // fresh live step count for this connected session + await engine.getBattery(); + await engine + .getStrapName(); // populate strap name + alarm for the Profile UI + _log('Listening (history first, live after catch-up).'); dbCounts = await LocalDb.counts(); _deriveScheduler.requestHeavy(); _startBackfillTimer(); @@ -1585,6 +1704,9 @@ class AppState extends ChangeNotifier { } finally { if (!engine.isConnected || !_keepAlive) { _stopBackfillTimer(); + BandOwnership.markForegroundIntent(false); + _log('[OWNERSHIP] foreground intent off (${BandOwnership.debugState})'); + _releaseForegroundLease(); } _setBusy(false); } @@ -1593,6 +1715,8 @@ class AppState extends ChangeNotifier { Future _reconnect() async { if (_reconnecting || paired == null) return; _reconnecting = true; + BandOwnership.markForegroundIntent(true); + _log('[OWNERSHIP] reconnect intent on (${BandOwnership.debugState})'); try { // Keep trying for as long as we still want the link (a session is active) — // a runner who left their phone behind can be out of range for an hour. @@ -1604,6 +1728,7 @@ class AppState extends ChangeNotifier { attempt++; await Future.delayed(engine.reconnectDelay(attempt)); if (!_keepAlive) break; + await _ensureForegroundLease(); if (await engine.connectToRemoteId(paired!.remoteId)) { // Reclaim the band from the iOS restore central so it stops competing. if (Platform.isIOS) { @@ -1614,7 +1739,11 @@ class AppState extends ChangeNotifier { // FULL drain (no short timeout): pull the ENTIRE offline backlog the band // buffered to flash while we were out of range. await _runSyncBurst(kickFirst: false); + await _refreshHighFreqWakeWindow(); await engine.enableLiveStreams(); + await engine.getBattery(); + await engine.getStrapName(); + await engine.getAlarm(); dbCounts = await LocalDb.counts(); _log('Reconnected — backlog drained.'); // Backlog (often an overnight gap) just landed → derive it. @@ -1626,6 +1755,10 @@ class AppState extends ChangeNotifier { } catch (e) { _log('Reconnect failed: $e'); } finally { + if (!_keepAlive) { + BandOwnership.markForegroundIntent(false); + _log('[OWNERSHIP] reconnect intent off (${BandOwnership.debugState})'); + } _reconnecting = false; } } @@ -1650,10 +1783,55 @@ class AppState extends ChangeNotifier { Future syncNow() => openSession(); + Future _refreshHighFreqWakeWindow() async { + if (!engine.isConnected) return; + try { + final plan = await HighFreqWakeWindow.planNow(); + await engine.applyHighFreqWakeWindow( + enabled: plan.shouldEnable, + targetWake: plan.targetWake, + duration: HighFreqWakeWindow.lease, + intervalSeconds: 60, + reason: plan.source, + ); + _log( + '[SYNC] HighFreq wake window: source=${plan.source} ' + 'samples=${plan.sampleCount} enabled=${plan.shouldEnable} ' + 'target=${plan.targetWake?.toIso8601String()}', + ); + } catch (e) { + _log('[SYNC] HighFreq wake window skipped: $e'); + } + } + Future endSession() async { _keepAlive = false; + BandOwnership.markForegroundIntent(false); + _log('[OWNERSHIP] endSession intent off (${BandOwnership.debugState})'); _stopBackfillTimer(); await engine.disconnect(); + _releaseForegroundLease(); + } + + Future _ensureForegroundLease() async { + if (_foregroundLease != null) return; + final lease = await BandOwnership.acquireForeground(); + _foregroundLease = lease; + _log( + '[OWNERSHIP] acquired foreground lease=${lease.token} ' + '(${BandOwnership.debugState})', + ); + } + + void _releaseForegroundLease() { + final lease = _foregroundLease; + if (lease == null) return; + _log( + '[OWNERSHIP] releasing foreground lease=${lease.token} ' + '(${BandOwnership.debugState})', + ); + BandOwnership.release(lease); + _foregroundLease = null; } String get status => device.connection; @@ -1819,8 +1997,10 @@ class AppState extends ChangeNotifier { if (next != null) { await LocalDb.putStepCalibration(next); learned = next.cadenceSpm; - _log('[steps] CALIBRATED → ${next.cadenceSpm.toStringAsFixed(0)} spm ' - '(refEnmo=${next.refEnmo.toStringAsFixed(3)}, n=${next.n})'); + _log( + '[steps] CALIBRATED → ${next.cadenceSpm.toStringAsFixed(0)} spm ' + '(refEnmo=${next.refEnmo.toStringAsFixed(3)}, n=${next.n})', + ); } } catch (e) { _log('[steps] calibration failed: $e'); diff --git a/lib/sync/android_boot_signal.dart b/lib/sync/android_boot_signal.dart new file mode 100644 index 00000000..7769c5d3 --- /dev/null +++ b/lib/sync/android_boot_signal.dart @@ -0,0 +1,29 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Native boot signal for Android headless launches. +/// +/// Native only returns true when: +/// - BootReceiver previously marked a pending headless boot +/// - no `MainActivity` is currently attached +/// +/// That closes the "first foreground open after reboot" hole where a pending +/// boot flag could otherwise be consumed by a normal UI launch. +class AndroidBootSignal { + AndroidBootSignal._(); + + static const MethodChannel _ch = MethodChannel('openstrap/edge_tracking'); + + static Future consumePendingHeadlessBoot() async { + if (!Platform.isAndroid) return false; + try { + return await _ch.invokeMethod('consumeHeadlessBootPending') ?? + false; + } catch (e) { + debugPrint('[android-boot-signal] consume failed: $e'); + return false; + } + } +} diff --git a/lib/sync/background_sync.dart b/lib/sync/background_sync.dart index 1247023a..efeea65e 100644 --- a/lib/sync/background_sync.dart +++ b/lib/sync/background_sync.dart @@ -18,6 +18,8 @@ import '../ble/ble_engine.dart'; import '../compute/derivation_engine.dart'; import '../compute/profile.dart'; import '../data/db.dart'; +import 'band_ownership.dart'; +import 'high_freq_wake_window.dart'; import 'paired_device.dart'; /// Load the local profile (no Provider in the headless isolate). @@ -36,8 +38,20 @@ Future _loadProfile() async { /// throws. Connects-by-id if reachable, drains whatever the band buffered to /// flash into local storage (non-destructive cursor — catches up everything since /// last time), and disconnects. No network. -Future runHeadlessSync() async { +Future runHeadlessSync({BandLease? lease}) async { WidgetsFlutterBinding.ensureInitialized(); + final ownedLease = lease ?? BandOwnership.tryAcquireHeadless(); + if (ownedLease == null) { + debugPrint( + '[bgsync] skipped — foreground or another headless session owns the band ' + '(${BandOwnership.debugState}).', + ); + return true; + } + debugPrint( + '[bgsync] acquired headless lease=${ownedLease.token} ' + '(${BandOwnership.debugState})', + ); try { final paired = await PairedDevice.load(); if (paired == null) { @@ -66,10 +80,25 @@ Future runHeadlessSync() async { // streaming when this returns. We then await it reaching HISTORY_COMPLETE. final connected = await engine.connectToRemoteId(paired.remoteId); if (!connected) { - debugPrint('[bgsync] strap not reachable this cycle — will catch up next time.'); + debugPrint( + '[bgsync] strap not reachable this cycle — will catch up next time.', + ); return true; } try { + final plan = await HighFreqWakeWindow.planNow(); + await engine.applyHighFreqWakeWindow( + enabled: plan.shouldEnable, + targetWake: plan.targetWake, + duration: HighFreqWakeWindow.lease, + intervalSeconds: 60, + reason: plan.source, + ); + debugPrint( + '[bgsync] HighFreq wake window: source=${plan.source} ' + 'samples=${plan.sampleCount} enabled=${plan.shouldEnable} ' + 'target=${plan.targetWake?.toIso8601String()}', + ); // Await the full backlog (default timeout): a phone-free run/sleep can leave a // large offline backlog on the band's flash. We never abort — if iOS cuts the // background window short, the offload persists what it got (flush-before-ACK) @@ -84,8 +113,9 @@ Future runHeadlessSync() async { // the short iOS execution budget). Best-effort; if the slot ends first, the // light pass on the next drain or the foreground finalize catches up. try { - await DerivationEngine(log: (l) => debugPrint('[bgsync-derive] $l')) - .run(await _loadProfile()); + await DerivationEngine( + log: (l) => debugPrint('[bgsync-derive] $l'), + ).run(await _loadProfile()); } catch (e) { debugPrint('[bgsync] derive skipped: $e'); } @@ -94,5 +124,11 @@ Future runHeadlessSync() async { } catch (e) { debugPrint('[bgsync] error (ignored): $e'); return true; + } finally { + debugPrint( + '[bgsync] releasing headless lease=${ownedLease.token} ' + '(${BandOwnership.debugState})', + ); + BandOwnership.release(ownedLease); } } diff --git a/lib/sync/band_ownership.dart b/lib/sync/band_ownership.dart new file mode 100644 index 00000000..44f80d5e --- /dev/null +++ b/lib/sync/band_ownership.dart @@ -0,0 +1,76 @@ +import 'dart:async'; + +enum BandOwnerKind { foreground, headless } + +class BandLease { + BandLease._(this.kind, this.token); + + final BandOwnerKind kind; + final int token; +} + +/// Process-local guard so only one BLE session owns the band at a time. +/// +/// Foreground is authoritative: once the UI intends to connect, new headless +/// work must back off. +class BandOwnership { + BandOwnership._(); + + static BandOwnerKind? _owner; + static int? _token; + static int _nextToken = 1; + static bool _foregroundIntent = false; + static Completer? _released; + + static BandOwnerKind? get owner => _owner; + static bool get foregroundIntent => _foregroundIntent; + static String get debugState => + 'owner=${_owner?.name ?? "none"} token=${_token ?? "-"} ' + 'foregroundIntent=$_foregroundIntent'; + + static void markForegroundIntent(bool active) { + _foregroundIntent = active; + } + + static Future acquireForeground({ + Duration poll = const Duration(milliseconds: 50), + }) async { + _foregroundIntent = true; + while (_owner != null && _owner != BandOwnerKind.foreground) { + await (_released ??= Completer()).future; + await Future.delayed(poll); + } + if (_owner == BandOwnerKind.foreground && _token != null) { + return BandLease._(BandOwnerKind.foreground, _token!); + } + final lease = BandLease._(BandOwnerKind.foreground, _nextToken++); + _owner = lease.kind; + _token = lease.token; + return lease; + } + + static BandLease? tryAcquireHeadless() { + if (_foregroundIntent || _owner != null) return null; + final lease = BandLease._(BandOwnerKind.headless, _nextToken++); + _owner = lease.kind; + _token = lease.token; + return lease; + } + + static void release(BandLease lease) { + if (_token != lease.token || _owner != lease.kind) return; + _owner = null; + _token = null; + final released = _released; + _released = null; + released?.complete(); + } + + static void resetForTest() { + _owner = null; + _token = null; + _nextToken = 1; + _foregroundIntent = false; + _released = null; + } +} diff --git a/lib/sync/headless_boot.dart b/lib/sync/headless_boot.dart index 84e64115..e0f93494 100644 --- a/lib/sync/headless_boot.dart +++ b/lib/sync/headless_boot.dart @@ -21,6 +21,8 @@ import 'dart:io'; import 'package:flutter/widgets.dart'; +import 'android_boot_signal.dart'; +import 'band_ownership.dart'; import '../sync/background_sync.dart'; import '../sync/edge_tracking.dart'; import '../sync/paired_device.dart'; @@ -46,20 +48,26 @@ Future maybeHeadlessBoot() async { if (_booted) return; _booted = true; - // Detect headless: are we running with no Activity attached to the engine? - // When launched via BootReceiver the engine runs but the binding's window/view - // is not ready. We use the heuristic that WidgetsBinding has no renderView / no - // view attached yet. A simpler check: if there are no views, we are headless. - final hasView = WidgetsBinding.instance.renderViews.isNotEmpty; - if (hasView) { - // A real Activity is attached — normal foreground launch, skip headless path. + final pendingBoot = await AndroidBootSignal.consumePendingHeadlessBoot(); + if (!pendingBoot) { return; } final paired = await PairedDevice.load(); if (paired == null) return; // nothing paired, nothing to do - debugPrint('[headless-boot] no view — headless boot, starting EdgeTracking'); + final lease = BandOwnership.tryAcquireHeadless(); + if (lease == null) { + debugPrint( + '[headless-boot] boot wake skipped — ${BandOwnership.debugState}', + ); + return; + } + + debugPrint( + '[headless-boot] boot wake confirmed — lease=${lease.token} ' + '${BandOwnership.debugState}', + ); // Ensure the foreground service is running (it was started by BootReceiver, but // calling start() again here is safe — EdgeTracking.start() is idempotent). await EdgeTracking.start(); @@ -68,7 +76,7 @@ Future maybeHeadlessBoot() async { // disconnect). This catches up the offline backlog accumulated while the phone // was powered off. Errors are swallowed inside runHeadlessSync. debugPrint('[headless-boot] starting headless sync for ${paired.remoteId}'); - runHeadlessSync().then((_) { + runHeadlessSync(lease: lease).then((_) { debugPrint('[headless-boot] headless sync complete'); }); } diff --git a/lib/sync/high_freq_wake_window.dart b/lib/sync/high_freq_wake_window.dart new file mode 100644 index 00000000..530abdb7 --- /dev/null +++ b/lib/sync/high_freq_wake_window.dart @@ -0,0 +1,96 @@ +import 'dart:convert'; + +import '../data/db.dart'; + +class HighFreqWakePlan { + final bool shouldEnable; + final DateTime? targetWake; + final String source; + final int sampleCount; + + const HighFreqWakePlan({ + required this.shouldEnable, + required this.targetWake, + required this.source, + required this.sampleCount, + }); +} + +class HighFreqWakeWindow { + static const Duration lease = Duration(minutes: 90); + static const int historyDays = 14; + static const int minSamples = 3; + + static Future planNow([DateTime? now]) async { + final rows = await LocalDb.recentDayResults(historyDays); + return planFromRows(rows, now ?? DateTime.now()); + } + + static HighFreqWakePlan planFromRows( + List> rows, + DateTime now, + ) { + final wakeMinutes = []; + for (final row in rows) { + final minute = _wakeMinuteOfDay(row); + if (minute != null) wakeMinutes.add(minute); + } + if (wakeMinutes.length < minSamples) { + return const HighFreqWakePlan( + shouldEnable: false, + targetWake: null, + source: 'insufficient_sleep_history', + sampleCount: 0, + ); + } + wakeMinutes.sort(); + final habitualWakeMinute = wakeMinutes[wakeMinutes.length ~/ 2]; + final todayTarget = DateTime( + now.year, + now.month, + now.day, + habitualWakeMinute ~/ 60, + habitualWakeMinute % 60, + ); + final targetWake = now.isAfter(todayTarget) + ? todayTarget.add(const Duration(days: 1)) + : todayTarget; + final windowStart = targetWake.subtract(lease); + return HighFreqWakePlan( + shouldEnable: !now.isBefore(windowStart) && now.isBefore(targetWake), + targetWake: targetWake, + source: 'habitual_wake', + sampleCount: wakeMinutes.length, + ); + } + + static int? _wakeMinuteOfDay(Map row) { + final win = _decodeMap(row['window_json']); + final payload = _decodeMap(row['payload_json']); + final winValue = _asMap(win['value']); + final sleep = _asMap(payload['sleep']); + final sleepWindow = _asMap(sleep['window']); + final sleepWindowValue = _asMap(sleepWindow['value']); + final offsetMs = + (winValue['offset_ms'] as num?)?.toInt() ?? + (sleepWindowValue['offset_ms'] as num?)?.toInt(); + if (offsetMs == null || offsetMs <= 0) return null; + final dt = DateTime.fromMillisecondsSinceEpoch(offsetMs); + return dt.hour * 60 + dt.minute; + } + + static Map _decodeMap(Object? raw) { + if (raw is Map) return raw.cast(); + if (raw is! String || raw.isEmpty) return const {}; + try { + final decoded = jsonDecode(raw); + if (decoded is Map) return decoded.cast(); + } catch (_) {} + return const {}; + } + + static Map _asMap(Object? raw) { + if (raw is Map) return raw.cast(); + return const {}; + } +} diff --git a/lib/sync/sync_policy.dart b/lib/sync/sync_policy.dart index bfc25aaa..77c088b3 100644 --- a/lib/sync/sync_policy.dart +++ b/lib/sync/sync_policy.dart @@ -12,14 +12,19 @@ import 'dart:math' as math; // ── timing constants (seconds) ─────────────────────────────────────────────── const int kBackfillIntervalSeconds = 900; // re-offload every 15 min (periodic) -const int kKeepAliveIntervalSeconds = 30; // re-arm realtime, poll battery, watchdog +const int kKeepAliveIntervalSeconds = + 30; // re-arm realtime, poll battery, watchdog const int kBackfillIdleTimeoutSeconds = 60; // strap went silent mid-offload const int kLivenessFuseSeconds = 120; // no data for >fuse ⇒ bounce the link +const int kHistoricalSendFloorSeconds = 5; // official app floors 0x16 to 5s +const int kHistoricalAbortRetryDelaySeconds = + 3; // official app retries 3s after abort // ── plausibility gates (unix seconds) ──────────────────────────────────────── const int kMinPlausibleUnix = 1700000000; // 2023-11 floor const int kFutureMargin = 86400; // +1 day -const int kSessionRangeMargin = 7 * 86400; // ±7 days around the strap's own range +const int kSessionRangeMargin = + 7 * 86400; // ±7 days around the strap's own range /// True iff [ts] (epoch sec) is a believable record time given wall-clock [wallNow] /// and — when known — the strap's own GET_DATA_RANGE window. An absolute @@ -60,7 +65,14 @@ class ClockPolicy { } // ── periodic-backfill rate policy ──────────────────────────────────────────── -enum BackfillTrigger { periodic, connect, foreground, manual, strap, autoContinue } +enum BackfillTrigger { + periodic, + connect, + foreground, + manual, + strap, + autoContinue, +} class BackfillPolicy { static const double periodicFloorSeconds = 900.0; @@ -81,9 +93,9 @@ class BackfillPolicy { final elapsed = now - lastBackfillAt; final backoff = emptyStreak >= emptyBackoffThreshold ? math - .pow(2.0, (emptyStreak - emptyBackoffThreshold + 1).toDouble()) - .toDouble() - .clamp(1.0, maxEmptyBackoff) + .pow(2.0, (emptyStreak - emptyBackoffThreshold + 1).toDouble()) + .toDouble() + .clamp(1.0, maxEmptyBackoff) : 1.0; switch (trigger) { case BackfillTrigger.manual: @@ -100,6 +112,14 @@ class BackfillPolicy { } } +class HistoricalSyncCommandPolicy { + static double waitSeconds(double? lastSendAt, double now) { + if (lastSendAt == null) return 0.0; + final remain = kHistoricalSendFloorSeconds - (now - lastSendAt); + return remain > 0 ? remain : 0.0; + } +} + // ── continuation: re-kick immediately instead of waiting 15 min ────────────── class BackfillContinuation { static const int defaultMaxAutoContinues = 6; @@ -107,14 +127,12 @@ class BackfillContinuation { /// Whether to immediately re-trigger an offload after a chunk drain / idle cap. /// ALL gates must hold: still connected, under the per-connection cap, the trim - /// cursor actually advanced (not spinning on a frozen cursor), and either the - /// strap is genuinely >5 min ahead of our frontier OR this session persisted - /// real rows (the strap's reported "newest" can be stale — #451). + /// cursor actually advanced (not spinning on a frozen cursor), and the strap is + /// genuinely still >5 min ahead of our frontier. static bool shouldAutoContinue({ required bool stillConnected, required int? strapNewestTs, required int? ourFrontierTs, - required int rowsPersistedThisSession, required bool lastTrimAdvanced, required int consecutiveCount, int maxAutoContinues = defaultMaxAutoContinues, @@ -124,9 +142,9 @@ class BackfillContinuation { if (consecutiveCount >= maxAutoContinues) return false; if (!lastTrimAdvanced) return false; if (strapNewestTs != null && ourFrontierTs != null) { - if ((strapNewestTs - ourFrontierTs) > behindGapSeconds) return true; + return (strapNewestTs - ourFrontierTs) > behindGapSeconds; } - return rowsPersistedThisSession > 0; + return false; } } @@ -136,18 +154,22 @@ class BackfillContinuation { class MarginalRadioDetector { final int tripThreshold; final double quickTimeoutWindow; - MarginalRadioDetector( - {this.tripThreshold = 2, this.quickTimeoutWindow = 20.0}); + MarginalRadioDetector({ + this.tripThreshold = 2, + this.quickTimeoutWindow = 20.0, + }); int _consecutive = 0; bool tripped = false; /// Feed a disconnect. Returns true exactly once, on the call that trips. - bool connectionEnded( - {required bool wasArmed, - required double? secondsSinceArm, - required bool timedOut}) { - final armCausedTimeout = wasArmed && + bool connectionEnded({ + required bool wasArmed, + required double? secondsSinceArm, + required bool timedOut, + }) { + final armCausedTimeout = + wasArmed && timedOut && secondsSinceArm != null && secondsSinceArm <= quickTimeoutWindow; @@ -175,17 +197,21 @@ class MarginalRadioDetector { class PostBondTimeoutLoopDetector { final int tripThreshold; final double quickTimeoutWindow; - PostBondTimeoutLoopDetector( - {this.tripThreshold = 2, this.quickTimeoutWindow = 8.0}); + PostBondTimeoutLoopDetector({ + this.tripThreshold = 2, + this.quickTimeoutWindow = 8.0, + }); int _consecutive = 0; bool tripped = false; - bool connectionEnded( - {required bool wasBonded, - required double? secondsSinceBond, - required bool timedOut}) { - final bondThenQuickTimeout = wasBonded && + bool connectionEnded({ + required bool wasBonded, + required double? secondsSinceBond, + required bool timedOut, + }) { + final bondThenQuickTimeout = + wasBonded && timedOut && secondsSinceBond != null && secondsSinceBond <= quickTimeoutWindow; @@ -217,8 +243,10 @@ class EmptySyncTracker { int _consecutive = 0; /// Feed a HISTORY_COMPLETE. Returns true on the call that crosses the threshold. - bool recordCompletedSync( - {required bool bankedSensorRecords, required bool consoleOnly}) { + bool recordCompletedSync({ + required bool bankedSensorRecords, + required bool consoleOnly, + }) { if (!consoleOnly || bankedSensorRecords) { _consecutive = 0; return false; @@ -265,8 +293,10 @@ class Whoop5EmptyOffloadTracker { class StuckStrapDetector { final double stuckAfterSeconds; final int behindGapSeconds; - StuckStrapDetector( - {this.stuckAfterSeconds = 600.0, this.behindGapSeconds = 300}); + StuckStrapDetector({ + this.stuckAfterSeconds = 600.0, + this.behindGapSeconds = 300, + }); int? _lastFrontierTs; double? _lastAdvanceWall; diff --git a/lib/telemetry/telemetry_service.dart b/lib/telemetry/telemetry_service.dart index 0d088205..dd2683a1 100644 --- a/lib/telemetry/telemetry_service.dart +++ b/lib/telemetry/telemetry_service.dart @@ -86,10 +86,10 @@ class TelemetryService { }) { _outbox.add({ 'kind': kind, - if (level != null) 'level': level, - if (message != null) 'message': _clip(message, 4000), - if (stack != null) 'stacktrace': _clip(stack, 8000), - if (context != null) 'context': context, + ...?level == null ? null : {'level': level}, + ...?message == null ? null : {'message': _clip(message, 4000)}, + ...?stack == null ? null : {'stacktrace': _clip(stack, 8000)}, + ...?context == null ? null : {'context': context}, 'ts': DateTime.now().millisecondsSinceEpoch ~/ 1000, }); while (_outbox.length > _maxOutbox) { diff --git a/lib/ui/profile/advanced_data_screen.dart b/lib/ui/profile/advanced_data_screen.dart new file mode 100644 index 00000000..83539e31 --- /dev/null +++ b/lib/ui/profile/advanced_data_screen.dart @@ -0,0 +1,413 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../data/db.dart'; +import '../../state/app_state.dart'; +import '../../theme/theme.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart'; + +class AdvancedDataScreen extends StatefulWidget { + const AdvancedDataScreen({super.key}); + + @override + State createState() => _AdvancedDataScreenState(); +} + +class _AdvancedDataScreenState extends State { + bool _loading = true; + bool _busy = false; + List> _days = const []; + List> _tableStats = const []; + Map? _capture; + Map? _today; + Map? _crossday; + final Set _selected = {}; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final app = context.read(); + setState(() => _loading = true); + final days = await app.dataHistoryDays(); + final tableStats = await LocalDb.tableStorageStats(); + final captureRow = await LocalDb.computeFreshness('capture'); + final todayRow = await LocalDb.computeFreshness('today'); + final crossdayRow = await LocalDb.computeFreshness('crossday'); + Map? decode(Map? row) { + final raw = row?['payload_json']; + if (raw is! String || raw.isEmpty) return null; + try { + final decoded = jsonDecode(raw); + return decoded is Map ? decoded.cast() : null; + } catch (_) { + return null; + } + } + + if (!mounted) return; + setState(() { + _days = days; + _tableStats = tableStats; + _capture = decode(captureRow); + _today = decode(todayRow); + _crossday = decode(crossdayRow); + _selected.removeWhere((d) => !_days.any((row) => row['day_id'] == d)); + _loading = false; + }); + } + + String _fmtMs(int? ms) { + if (ms == null || ms <= 0) return '—'; + final dt = DateTime.fromMillisecondsSinceEpoch(ms); + final hh = dt.hour.toString().padLeft(2, '0'); + final mm = dt.minute.toString().padLeft(2, '0'); + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} $hh:$mm'; + } + + String _fmtMb(num? mb) { + if (mb == null || !mb.isFinite) return '—'; + if (mb >= 100) return '${mb.toStringAsFixed(0)} MB'; + if (mb >= 10) return '${mb.toStringAsFixed(1)} MB'; + return '${mb.toStringAsFixed(2)} MB'; + } + + String _fmtRows(Object? rows) { + final n = (rows as num?)?.toInt(); + if (n == null) return '—'; + return n.toString(); + } + + Future _reanalyzeSelected() async { + if (_selected.isEmpty) return; + final app = context.read(); + setState(() => _busy = true); + try { + final n = await app.reanalyzeDays(_selected); + if (!mounted) return; + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + n > 0 + ? 'Recomputed $n selected day${n == 1 ? '' : 's'}.' + : 'No selected days were recomputed.', + ), + ), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _reanalyzeAll() async { + final app = context.read(); + setState(() => _busy = true); + try { + final n = await app.reanalyzeAll(); + if (!mounted) return; + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + n > 0 + ? 'Recomputed $n day${n == 1 ? '' : 's'}.' + : 'No raw data to analyze yet.', + ), + ), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final app = context.watch(); + return Scaffold( + backgroundColor: AppColors.bg, + body: SafeArea( + bottom: false, + child: ListView( + padding: const EdgeInsets.fromLTRB( + Sp.screen, + Sp.x4, + Sp.screen, + Sp.x8, + ), + children: [ + Row( + children: [ + RoundIconButton( + Ic.arrowLeft, + onTap: () => Navigator.of(context).maybePop(), + ), + const SizedBox(width: Sp.x3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Advanced data', style: AppText.h1), + const SizedBox(height: 2), + Text( + 'Developer tools for compute and sync', + style: AppText.caption, + ), + ], + ), + ), + if (_busy || _loading) + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ), + const SizedBox(height: Sp.x6), + SectionHeader( + 'Compute', + trailing: app.reanalyzing ? app.reanalyzeProgress : null, + ), + ProCard( + child: Column( + children: [ + DetailRow( + icon: Ic.history, + label: 'Recompute selected days', + value: _selected.isEmpty ? 'Select first' : 'Run', + onTap: _busy || _selected.isEmpty + ? null + : _reanalyzeSelected, + ), + const Divider(height: 1), + DetailRow( + icon: Ic.history, + label: 'Recompute all days', + value: _busy ? 'Working…' : 'Run', + onTap: _busy ? null : _reanalyzeAll, + ), + ], + ), + ), + const SizedBox(height: Sp.x6), + const SectionHeader('Status'), + ProCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _kv( + 'Latest data day', + _capture?['latest_raw_day']?.toString() ?? '—', + ), + _kv( + 'Latest data rec_ts', + (_capture?['latest_raw_rec_ts'] ?? '—').toString(), + ), + _kv( + 'Decoded 1 Hz rows', + (_capture?['decoded_onehz'] ?? '—').toString(), + ), + _kv( + 'Decoded RR rows', + (_capture?['decoded_rr'] ?? '—').toString(), + ), + const SizedBox(height: Sp.x3), + _kv( + 'Today activity', + _today?['activity_state']?.toString() ?? '—', + ), + _kv( + 'Today overnight', + _today?['overnight_state']?.toString() ?? '—', + ), + _kv( + 'Activity computed', + _fmtMs((_today?['activity_computed_at'] as num?)?.toInt()), + ), + _kv( + 'Overnight computed', + _fmtMs((_today?['overnight_computed_at'] as num?)?.toInt()), + ), + const SizedBox(height: Sp.x3), + _kv( + 'Cross-day baseline', + _crossday?['present'] == true ? 'Present' : 'Missing', + ), + _kv( + 'Cross-day updated', + _fmtMs((_crossday?['updated_at'] as num?)?.toInt()), + ), + ], + ), + ), + const SizedBox(height: Sp.x6), + const SectionHeader('Storage'), + ProCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final row in _tableStats) ...[ + _tableRow(row), + if (!identical(row, _tableStats.last)) + const Padding( + padding: EdgeInsets.symmetric(vertical: Sp.x2), + child: Divider(height: 1), + ), + ], + ], + ), + ), + const SizedBox(height: Sp.x6), + SectionHeader( + 'Days', + trailing: _days.isEmpty + ? null + : (_selected.length == _days.length ? 'Clear' : 'Select all'), + onTrailing: _days.isEmpty + ? null + : () { + setState(() { + if (_selected.length == _days.length) { + _selected.clear(); + } else { + _selected + ..clear() + ..addAll(_days.map((d) => d['day_id'] as String)); + } + }); + }, + ), + if (_loading) + const Padding( + padding: EdgeInsets.only(top: Sp.x8), + child: Center(child: CircularProgressIndicator()), + ) + else + Column( + children: [ + for (final row in _days) ...[ + _dayCard(row), + const SizedBox(height: Sp.x3), + ], + ], + ), + ], + ), + ), + ); + } + + Widget _kv(String k, String v) => Padding( + padding: const EdgeInsets.only(bottom: Sp.x2), + child: Row( + children: [ + Expanded(child: Text(k, style: AppText.body)), + const SizedBox(width: Sp.x3), + Text(v, style: AppText.bodySoft), + ], + ), + ); + + Widget _tableRow(Map row) { + final approx = row['approximate'] == true; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text(row['table']?.toString() ?? '—', style: AppText.body), + ), + const SizedBox(width: Sp.x3), + Text('${_fmtRows(row['rows'])} rows', style: AppText.captionMuted), + const SizedBox(width: Sp.x3), + Text( + _fmtMb((row['mb'] as num?)), + style: approx ? AppText.captionMuted : AppText.bodySoft, + ), + ], + ); + } + + Widget _dayCard(Map row) { + final dayId = row['day_id'] as String; + final selected = _selected.contains(dayId); + final rawCount = (row['raw_count'] as int?) ?? 0; + final hasDerived = row['has_derived'] == true; + final finalized = ((row['finalized'] as int?) ?? 0) == 1; + return ProCard( + onTap: () => setState(() { + if (selected) { + _selected.remove(dayId); + } else { + _selected.add(dayId); + } + }), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Checkbox( + value: selected, + onChanged: (_) => setState(() { + if (selected) { + _selected.remove(dayId); + } else { + _selected.add(dayId); + } + }), + ), + const SizedBox(width: Sp.x2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text(dayId, style: AppText.title)), + Tag( + hasDerived + ? (finalized ? 'finalized' : 'derived') + : (rawCount > 0 ? 'raw-only' : 'empty'), + color: hasDerived + ? (finalized + ? AppColors.goodSoft + : AppColors.coralSoft) + : AppColors.surfaceAlt, + ), + ], + ), + const SizedBox(height: Sp.x2), + Wrap( + spacing: Sp.x3, + runSpacing: Sp.x2, + children: [ + Text('Raw $rawCount', style: AppText.captionMuted), + Text( + 'Derived ${hasDerived ? 'yes' : 'no'}', + style: AppText.captionMuted, + ), + Text( + 'Algo ${(row['algo_version'] as num?)?.toInt() ?? '—'}', + style: AppText.captionMuted, + ), + Text( + 'Metrics ${(row['metric_count'] as int?) ?? 0}', + style: AppText.captionMuted, + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/profile/data_history_screen.dart b/lib/ui/profile/data_history_screen.dart new file mode 100644 index 00000000..00e695a9 --- /dev/null +++ b/lib/ui/profile/data_history_screen.dart @@ -0,0 +1,379 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../data/db.dart'; +import '../../state/app_state.dart'; +import '../../theme/theme.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart'; + +class DataHistoryScreen extends StatefulWidget { + const DataHistoryScreen({super.key}); + + @override + State createState() => _DataHistoryScreenState(); +} + +class _DataHistoryScreenState extends State { + bool _loading = true; + bool _busy = false; + int _dbBytes = 0; + List> _days = const []; + final Set _selected = {}; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final app = context.read(); + setState(() => _loading = true); + final days = await app.dataHistoryDays(); + final bytes = await app.dataFileBytes(); + if (!mounted) return; + setState(() { + _days = days; + _dbBytes = bytes; + _selected.removeWhere((d) => !_days.any((row) => row['day_id'] == d)); + _loading = false; + }); + } + + String _fmtBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; + } + + String _fmtTs(int? sec) { + if (sec == null || sec <= 0) return '—'; + final dt = DateTime.fromMillisecondsSinceEpoch(sec * 1000); + final hh = dt.hour.toString().padLeft(2, '0'); + final mm = dt.minute.toString().padLeft(2, '0'); + return '$hh:$mm'; + } + + String _fmtComputed(int? ms) { + if (ms == null || ms <= 0) return '—'; + final dt = DateTime.fromMillisecondsSinceEpoch(ms); + return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; + } + + Future _shareWholeDb() async { + setState(() => _busy = true); + try { + final path = await LocalDb.exportCopy(); + if (!mounted) return; + await Share.shareXFiles([XFile(path)], text: 'OpenStrap data export'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _shareSelected() async { + if (_selected.isEmpty) return; + final app = context.read(); + setState(() => _busy = true); + try { + final path = await app.exportDaysDb(_selected); + if (!mounted) return; + await Share.shareXFiles( + [XFile(path)], + text: + 'OpenStrap selected day export (${_selected.length} day${_selected.length == 1 ? '' : 's'})', + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Export failed: $e'))); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _deleteSelected() async { + if (_selected.isEmpty) return; + final app = context.read(); + final count = _selected.length; + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete selected days?'), + content: Text( + 'This removes local raw and derived data for $count selected day${count == 1 ? '' : 's'}. Export first if you may need it later.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirm != true) return; + setState(() => _busy = true); + try { + final deleted = await app.deleteDays(_selected); + if (!mounted) return; + _selected.clear(); + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Deleted $deleted local row${deleted == 1 ? '' : 's'} across selected days.', + ), + ), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final selected = _selected.length; + final rawDays = _days + .where((d) => ((d['raw_count'] as int?) ?? 0) > 0) + .length; + final derivedDays = _days.where((d) => d['has_derived'] == true).length; + return Scaffold( + backgroundColor: AppColors.bg, + body: SafeArea( + bottom: false, + child: ListView( + padding: const EdgeInsets.fromLTRB( + Sp.screen, + Sp.x4, + Sp.screen, + Sp.x8, + ), + children: [ + Row( + children: [ + RoundIconButton( + Ic.arrowLeft, + onTap: () => Navigator.of(context).maybePop(), + ), + const SizedBox(width: Sp.x3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Data history', style: AppText.h1), + const SizedBox(height: 2), + Text('Manage your local data', style: AppText.caption), + ], + ), + ), + if (_loading || _busy) + const Padding( + padding: EdgeInsets.only(right: Sp.x2), + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ], + ), + const SizedBox(height: Sp.x6), + ProCard( + child: Row( + children: [ + Expanded(child: _stat('Local size', _fmtBytes(_dbBytes))), + const SizedBox(width: Sp.x3), + Expanded(child: _stat('Days with data', '$rawDays')), + const SizedBox(width: Sp.x3), + Expanded(child: _stat('Days derived', '$derivedDays')), + ], + ), + ), + const SizedBox(height: Sp.x6), + SectionHeader( + 'Actions', + trailing: selected > 0 ? '$selected selected' : 'Select days', + ), + ProCard( + child: Column( + children: [ + DetailRow( + icon: Ic.cloud, + label: 'Export full database', + value: 'Share .db', + onTap: _busy ? null : _shareWholeDb, + ), + const Divider(height: 1), + DetailRow( + icon: Ic.history, + label: 'Export selected days', + value: selected == 0 ? 'Select first' : 'Share .db', + onTap: _busy || selected == 0 ? null : _shareSelected, + ), + const Divider(height: 1), + DetailRow( + icon: Ic.trash, + label: 'Delete selected days', + value: selected == 0 ? 'Select first' : 'Remove local data', + onTap: _busy || selected == 0 ? null : _deleteSelected, + ), + ], + ), + ), + const SizedBox(height: Sp.x6), + SectionHeader( + 'Days', + trailing: _days.isEmpty + ? null + : (_selected.length == _days.length ? 'Clear' : 'Select all'), + onTrailing: _days.isEmpty + ? null + : () { + setState(() { + if (_selected.length == _days.length) { + _selected.clear(); + } else { + _selected + ..clear() + ..addAll(_days.map((d) => d['day_id'] as String)); + } + }); + }, + ), + if (_loading) + const Padding( + padding: EdgeInsets.only(top: Sp.x8), + child: Center(child: CircularProgressIndicator()), + ) + else if (_days.isEmpty) + ProCard( + child: Text( + 'No local day history yet.', + style: AppText.bodySoft, + ), + ) + else + Column( + children: [ + for (final row in _days) ...[ + _dayCard(row), + const SizedBox(height: Sp.x3), + ], + ], + ), + ], + ), + ), + ); + } + + Widget _stat(String label, String value) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(value, style: AppText.metricSm.copyWith(fontSize: 22)), + const SizedBox(height: 2), + Text(label, style: AppText.captionMuted), + ], + ); + + Widget _pill(String text, Color color, Color fg) => Container( + padding: const EdgeInsets.symmetric(horizontal: Sp.x2, vertical: 6), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(R.chip), + ), + child: Text(text, style: AppText.caption.copyWith(color: fg)), + ); + + Widget _dayCard(Map row) { + final dayId = row['day_id'] as String; + final selected = _selected.contains(dayId); + final rawCount = (row['raw_count'] as int?) ?? 0; + final hasDerived = row['has_derived'] == true; + final finalized = ((row['finalized'] as int?) ?? 0) == 1; + return ProCard( + onTap: () => setState(() { + if (selected) { + _selected.remove(dayId); + } else { + _selected.add(dayId); + } + }), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Checkbox( + value: selected, + onChanged: (_) => setState(() { + if (selected) { + _selected.remove(dayId); + } else { + _selected.add(dayId); + } + }), + ), + const SizedBox(width: Sp.x2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text(dayId, style: AppText.title)), + if (hasDerived) + _pill( + finalized ? 'Finalized' : 'Derived', + finalized ? AppColors.goodSoft : AppColors.coralSoft, + finalized ? AppColors.good : AppColors.coralDeep, + ) + else + _pill( + rawCount > 0 ? 'Raw only' : 'Empty', + AppColors.surfaceAlt, + AppColors.inkMuted, + ), + ], + ), + const SizedBox(height: Sp.x2), + Wrap( + spacing: Sp.x3, + runSpacing: Sp.x2, + children: [ + Text('Raw $rawCount', style: AppText.captionMuted), + Text( + 'Span ${_fmtTs((row['min_rec_ts'] as num?)?.toInt())}–${_fmtTs((row['max_rec_ts'] as num?)?.toInt())}', + style: AppText.captionMuted, + ), + Text( + 'Metrics ${(row['metric_count'] as int?) ?? 0}', + style: AppText.captionMuted, + ), + Text( + 'Workouts ${(row['session_count'] as int?) ?? 0}', + style: AppText.captionMuted, + ), + Text( + 'Computed ${_fmtComputed((row['computed_at'] as num?)?.toInt())}', + style: AppText.captionMuted, + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/profile/profile_screen.dart b/lib/ui/profile/profile_screen.dart index 9f3aed88..0cd33fc1 100644 --- a/lib/ui/profile/profile_screen.dart +++ b/lib/ui/profile/profile_screen.dart @@ -5,19 +5,20 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../../data/db.dart'; import '../../health/health_export.dart'; import '../../state/app_state.dart'; import '../../state/units_controller.dart'; +import '../../debug/debug_mode.dart'; import '../../theme/theme.dart'; import '../../theme/theme_switcher.dart'; import '../../theme/tokens.dart'; import '../import/import_screen.dart'; import '../kit/kit.dart'; import '../today/step_goal_screen.dart'; +import 'advanced_data_screen.dart'; +import 'data_history_screen.dart'; import 'gesture_section.dart'; import 'notification_relay_section.dart'; import 'notification_settings_screen.dart'; @@ -187,63 +188,35 @@ class ProfileScreen extends StatelessWidget { ), child: DetailRow( icon: Ic.history, - label: 'Re-analyze data', - value: app.reanalyzing - ? (app.reanalyzeProgress.isEmpty - ? 'Working…' - : app.reanalyzeProgress) - : 'Run', - onTap: () async { - if (app.reanalyzing) return; - final messenger = ScaffoldMessenger.of(context); - final n = await app.reanalyzeAll(); - messenger.showSnackBar( - SnackBar( - content: Text( - n > 0 - ? 'Analyzed $n day${n == 1 ? '' : 's'} of stored data.' - : 'No raw data to analyze yet.', - ), - ), - ); - }, + label: 'Data history', + value: 'Manage', + onTap: () => Navigator.of( + context, + ).push(themedRoute((_) => const DataHistoryScreen())), ), ), const SizedBox(height: Sp.x3), - // Export the local SQLite store (transactionally-consistent VACUUM INTO - // snapshot) via the share sheet — for backup, moving to a new device - // ("Import from Edge"), or sharing for debugging. - ProCard( - padding: const EdgeInsets.symmetric( - horizontal: Sp.x5, - vertical: Sp.x2, - ), - child: Builder( - builder: (rowCtx) => DetailRow( - icon: Ic.cloud, - label: 'Export data (.db)', - value: 'Share', - onTap: () async { - final messenger = ScaffoldMessenger.of(rowCtx); - final box = rowCtx.findRenderObject() as RenderBox?; - try { - final path = await LocalDb.exportCopy(); - await Share.shareXFiles( - [XFile(path)], - text: 'OpenStrap data export', - sharePositionOrigin: box != null - ? box.localToGlobal(Offset.zero) & box.size - : null, - ); - } catch (e) { - messenger.showSnackBar( - SnackBar(content: Text('Export failed: $e')), - ); - } - }, + if (advancedDebugMode) ...[ + ProCard( + padding: const EdgeInsets.symmetric( + horizontal: Sp.x5, + vertical: Sp.x2, + ), + child: DetailRow( + icon: Ic.settings, + label: 'Advanced data', + value: app.reanalyzing + ? (app.reanalyzeProgress.isEmpty + ? 'Working…' + : app.reanalyzeProgress) + : 'Debug tools', + onTap: () => Navigator.of( + context, + ).push(themedRoute((_) => const AdvancedDataScreen())), ), ), - ), + const SizedBox(height: Sp.x3), + ], const SizedBox(height: Sp.x7), @@ -429,13 +402,16 @@ class ProfileScreen extends StatelessWidget { const SectionHeader('Notifications'), ProCard( padding: const EdgeInsets.symmetric( - horizontal: Sp.x5, vertical: Sp.x2), + horizontal: Sp.x5, + vertical: Sp.x2, + ), child: DetailRow( icon: Ic.bell, label: 'Alerts & reminders', value: 'Manage', - onTap: () => Navigator.of(context).push( - themedRoute((_) => const NotificationSettingsScreen())), + onTap: () => Navigator.of( + context, + ).push(themedRoute((_) => const NotificationSettingsScreen())), ), ), // Notification relay (Android only — self-hides on iOS). @@ -486,7 +462,7 @@ class ProfileScreen extends StatelessWidget { ), const SizedBox(height: Sp.x3), Text( - 'Your raw band data and metrics are stored entirely on this ' + 'Your band data and metrics are stored entirely on this ' 'phone. Nothing is uploaded to a server.', style: AppText.captionMuted, ), diff --git a/lib/ui/screens/metric_row.dart b/lib/ui/screens/metric_row.dart index de3c3c57..f28a4794 100644 --- a/lib/ui/screens/metric_row.dart +++ b/lib/ui/screens/metric_row.dart @@ -41,7 +41,7 @@ const Map kMetricInfo = { 'sleeping_hr': 'Average heart rate while you slept.', 'resp': 'Breaths per minute, derived from heart-rate variability.', 'spo2': - 'Overnight red/IR oxygen screen. Dips are relative to your own nightly baseline, not an absolute SpO₂%.', + 'TODO. Blood O₂ is temporarily disabled while the packet decode is re-validated from raw captures.', 'skin_temp': 'Skin temperature vs your personal overnight baseline. Relative (Δ), not an absolute thermometer.', 'hrr60': diff --git a/lib/ui/screens/screens.dart b/lib/ui/screens/screens.dart index 03af7b98..e1cc2ad1 100644 --- a/lib/ui/screens/screens.dart +++ b/lib/ui/screens/screens.dart @@ -72,14 +72,53 @@ class HeartScreen extends StatelessWidget { class OxygenScreen extends StatelessWidget { const OxygenScreen({super.key}); @override - Widget build(BuildContext context) => MetricScreen( - title: 'Overnight oxygen', - metric: 'spo2', - icon: Ic.droplet, - accent: AppColors.coralDeep, - valueFmt: (v) => v == 0 ? '0' : v.toStringAsFixed(1), - todayDetail: (ctx) => OxygenDayCard(date: todayUtc()), - dayDetail: (ctx, date) => OxygenDayCard(date: date), + Widget build(BuildContext context) => Scaffold( + backgroundColor: AppColors.bg, + appBar: AppBar(title: const Text('Blood O₂')), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(Sp.x4), + child: ProCard( + child: Padding( + padding: const EdgeInsets.all(Sp.x5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.coralDeep.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(R.chip), + ), + child: AppIcon( + Ic.droplet, + size: 18, + color: AppColors.coralDeep, + ), + ), + const SizedBox(width: Sp.x3), + Text('TODO', style: AppText.metric), + ], + ), + const SizedBox(height: Sp.x4), + Text( + 'Blood O₂ is temporarily disabled while packet decoding is being re-validated.', + style: AppText.body, + ), + const SizedBox(height: Sp.x3), + Text( + 'Raw red/IR data is still being logged so the feature can be rebuilt from real captures instead of guesswork.', + style: AppText.bodySoft, + ), + ], + ), + ), + ), + ), + ), ); } diff --git a/lib/ui/today/today_screen.dart b/lib/ui/today/today_screen.dart index 1dbf2a05..54697c77 100644 --- a/lib/ui/today/today_screen.dart +++ b/lib/ui/today/today_screen.dart @@ -110,18 +110,6 @@ class _TodayScreenState extends State /// Round a metric's value to an int string, or null when empty. String? _int(Metric m) => m.isEmpty ? null : m.value!.round().toString(); - Widget _oxygenQualityTag(Spo2Data? spo2) { - if (spo2 == null) return Tag('beta', color: AppColors.coral); - final trusted = spo2.trustedCoverage ?? spo2.signalCoverage ?? 0; - if (trusted >= 0.85) { - return Tag('clean', color: AppColors.good); - } - if (trusted >= 0.60) { - return Tag('usable', color: AppColors.warn); - } - return Tag('low signal', color: AppColors.coral); - } - /// Today as 'YYYY-MM-DD' (UTC, matching the backend's day keys). String _todayStr() { final n = DateTime.now().toUtc(); @@ -292,17 +280,15 @@ class _TodayScreenState extends State // ── content ────────────────────────────────────────────────────────────────── List _content(TodayData t) { + final app = context.read(); final alert = t.bodyAlert; final coach = t.coach; final status = t.status; return [ if (alert != null) ...[_bodyAlert(alert), const SizedBox(height: Sp.x4)], - if (status != null && - (status.overnightBuilding || - status.activityBuilding || - status.showingPriorOvernight)) ...[ - _todayStatusCard(status), + if (_shouldShowTodayStatus(app, status)) ...[ + _todayStatusCard(app, status), const SizedBox(height: Sp.x4), ], // Composite Readiness headline. Shows the score when present, or a @@ -395,13 +381,11 @@ class _TodayScreenState extends State _statRow( StatTile( icon: Ic.heart, - label: 'Oxygen dips', - value: t.spo2?.odiPerHour?.toStringAsFixed(1), - unit: '/h', + label: 'Blood O₂', + value: 'TODO', + unit: null, accent: AppColors.coralDeep, - tag: _oxygenQualityTag(t.spo2), - confidence: t.spo2?.confidence, - onTap: () => _push(() => const OxygenScreen()), + tag: Tag('decode pending', color: AppColors.coral), ), _bodyOverTimeTile(), ), @@ -850,10 +834,10 @@ class _TodayScreenState extends State /// Honest empty/processing state. Three cases, never a blank-with-no-reason: /// • analysis running → "Processing… N/M days" with a spinner. - /// • raw collected, not yet derived → invite to analyze now (shows record count). + /// • decoded data collected, not yet derived → invite to analyze now. /// • truly no data → "Wear + sync to see today". Widget _emptyOrProcessing(AppState app) { - final raw = app.dbCounts['raw'] ?? 0; + final raw = app.dbCounts['decoded_onehz'] ?? app.dbCounts['raw'] ?? 0; if (app.reanalyzing) { return _processing( app.reanalyzeProgress.isEmpty @@ -916,7 +900,7 @@ class _TodayScreenState extends State ), const SizedBox(height: Sp.x2), Text( - 'Stored $raw raw record${raw == 1 ? '' : 's'} from your strap. ' + 'Stored $raw decoded sample${raw == 1 ? '' : 's'} from your strap. ' 'Analysis runs automatically after a sync — or run it now.', style: AppText.bodySoft, textAlign: TextAlign.center, @@ -952,23 +936,55 @@ class _TodayScreenState extends State ); } - Widget _todayStatusCard(TodayStatus status) { + bool _shouldShowTodayStatus(AppState app, TodayStatus? status) { + final last = app.lastRecordAt; + final stale = + last == null || DateTime.now().difference(last).inMinutes >= 60; + if (stale) return true; + if (status == null) return false; + return status.overnightBuilding || + status.activityBuilding || + status.showingPriorOvernight; + } + + Widget _todayStatusCard(AppState app, TodayStatus? status) { + final last = app.lastRecordAt; + final stale = + last == null || DateTime.now().difference(last).inMinutes >= 60; + final capture = app.pipelineStatus['capture'] as Map?; + final derive = app.pipelineStatus['derive'] as Map?; + final captureActive = capture?['active'] == true; + final deriveRunning = derive?['running'] == true; + final pendingLight = derive?['pending_light'] == true; + final pendingHeavy = derive?['pending_heavy'] == true; + String label; - if (status.overnightBuilding && status.activityBuilding) { + if (stale && + (captureActive || deriveRunning || pendingLight || pendingHeavy)) { + label = + 'Your latest band data is more than an hour behind. OpenStrap is catching up now and this page will refresh automatically when sleep and today\'s metrics are ready.'; + } else if (stale && app.isConnected) { + label = + 'Your latest band data is more than an hour behind. OpenStrap is connected and waiting for the next data handoff.'; + } else if (stale) { + label = + 'Your latest band data is more than an hour behind. Reconnect the band and this page will refresh automatically once new data is captured and computed.'; + } else if (status?.overnightBuilding == true && + status?.activityBuilding == true) { label = 'Today\'s activity is landing and the overnight metrics are still settling.'; - } else if (status.overnightBuilding) { + } else if (status?.overnightBuilding == true) { label = 'Today\'s overnight metrics are still computing. Sleep and readiness will fill when that pass finishes.'; - } else if (status.activityBuilding) { + } else if (status?.activityBuilding == true) { label = 'Fresh data is in for today, but the day metrics are still catching up.'; } else { label = 'Showing the last settled overnight while today\'s overnight metrics have not landed yet.'; } - final overnight = status.overnightDay; - final extra = status.showingPriorOvernight && overnight != null + final overnight = status?.overnightDay; + final extra = status?.showingPriorOvernight == true && overnight != null ? ' Last settled night: $overnight.' : ''; return ProCard( diff --git a/lib/ui/widgets/screen_loader.dart b/lib/ui/widgets/screen_loader.dart index 619f2cb9..0c3b2048 100644 --- a/lib/ui/widgets/screen_loader.dart +++ b/lib/ui/widgets/screen_loader.dart @@ -18,6 +18,33 @@ import '../../state/app_state.dart'; enum LoadPhase { loading, ready, empty, error } +@visibleForTesting +class ScreenRefreshGate { + bool _busy = false; + bool _queued = false; + + bool get busy => _busy; + bool get queued => _queued; + + bool tryBegin() { + if (_busy) { + _queued = true; + return false; + } + _busy = true; + return true; + } + + bool finishAndShouldReplay() { + _busy = false; + if (_queued) { + _queued = false; + return true; + } + return false; + } +} + /// Mix into a screen `State`. Provide [cacheKey] (kept for parity / future local /// cache), [fetch] (raw payload), and [isEmpty] (does the payload have nothing?). mixin ScreenLoaderMixin on State { @@ -29,14 +56,31 @@ mixin ScreenLoaderMixin on State { LoadPhase phase = LoadPhase.loading; String? errorText; bool fromCache = false; - bool _busy = false; + final ScreenRefreshGate _refreshGate = ScreenRefreshGate(); Timer? _timer; + AppState? _app; + VoidCallback? _insightsListener; + int _lastInsightsRevision = -1; @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => refresh()); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _app = context.read(); + _lastInsightsRevision = _app!.insightsRevision.value; + _insightsListener = () { + final app = _app; + if (!mounted || app == null) return; + final next = app.insightsRevision.value; + if (next == _lastInsightsRevision) return; + _lastInsightsRevision = next; + refresh(background: true); + }; + _app!.insightsRevision.addListener(_insightsListener!); + refresh(); + }); _timer = Timer.periodic(const Duration(seconds: 90), (_) { if (mounted) refresh(background: true); }); @@ -45,13 +89,18 @@ mixin ScreenLoaderMixin on State { @override void dispose() { _timer?.cancel(); + final listener = _insightsListener; + final app = _app; + if (listener != null && app != null) { + app.insightsRevision.removeListener(listener); + } super.dispose(); } /// Pull-to-refresh / background refresh. [background] keeps the current view /// while fetching (no loading flash). Future refresh({bool background = false}) async { - if (_busy) return; + if (!_refreshGate.tryBegin()) return; final app = context.read(); final repo = app.repo; if (repo == null) { @@ -61,9 +110,9 @@ mixin ScreenLoaderMixin on State { errorText = 'Local insights are not available yet.'; }); } + _refreshGate.finishAndShouldReplay(); return; } - _busy = true; if (!background && data == null && mounted) { setState(() => phase = LoadPhase.loading); } @@ -91,7 +140,9 @@ mixin ScreenLoaderMixin on State { } }); } finally { - _busy = false; + if (_refreshGate.finishAndShouldReplay() && mounted) { + unawaited(refresh(background: true)); + } } } diff --git a/pubspec.lock b/pubspec.lock index e4437023..3b9ee978 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -668,10 +668,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -731,16 +731,20 @@ packages: openstrap_analytics: dependency: "direct main" description: - path: "../openstrap-analytics-onehz" - relative: true - source: path + path: "." + ref: main + resolved-ref: "092b474915c6e6e90939998ac9fe6ab5869154de" + url: "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/OpenStrap/analytics.git" + source: git version: "1.0.0" openstrap_protocol: dependency: "direct main" description: - path: "../openstrap-protocol-dart" - relative: true - source: path + path: "." + ref: main + resolved-ref: d72b61be7f996b21a660d6765a948be72088fb8f + url: "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/OpenStrap/protocol.git" + source: git version: "1.0.0" ota_update: dependency: "direct main" @@ -1143,26 +1147,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.17" timezone: dependency: "direct main" description: diff --git a/test/band_ownership_test.dart b/test/band_ownership_test.dart new file mode 100644 index 00000000..b6469ecf --- /dev/null +++ b/test/band_ownership_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/sync/band_ownership.dart'; + +void main() { + setUp(BandOwnership.resetForTest); + + test('headless cannot acquire while foreground intent is active', () { + BandOwnership.markForegroundIntent(true); + expect(BandOwnership.tryAcquireHeadless(), isNull); + }); + + test('foreground waits for active headless owner to release', () async { + final headless = BandOwnership.tryAcquireHeadless(); + expect(headless, isNotNull); + + final future = BandOwnership.acquireForeground(); + await Future.delayed(const Duration(milliseconds: 80)); + expect(BandOwnership.owner, BandOwnerKind.headless); + + BandOwnership.release(headless!); + final foreground = await future; + expect(foreground.kind, BandOwnerKind.foreground); + expect(BandOwnership.owner, BandOwnerKind.foreground); + }); + + test( + 'headless acquires when band is free and no foreground intent exists', + () { + final lease = BandOwnership.tryAcquireHeadless(); + expect(lease, isNotNull); + expect(BandOwnership.owner, BandOwnerKind.headless); + }, + ); + + test('released foreground owner allows later headless recovery', () async { + final foreground = await BandOwnership.acquireForeground(); + expect(BandOwnership.owner, BandOwnerKind.foreground); + + BandOwnership.markForegroundIntent(false); + BandOwnership.release(foreground); + + final headless = BandOwnership.tryAcquireHeadless(); + expect(headless, isNotNull); + expect(BandOwnership.owner, BandOwnerKind.headless); + }); + + test('foreground acquire is re-entrant for the same process owner', () async { + final first = await BandOwnership.acquireForeground(); + final second = await BandOwnership.acquireForeground(); + + expect(first.kind, BandOwnerKind.foreground); + expect(second.kind, BandOwnerKind.foreground); + expect(second.token, first.token); + expect(BandOwnership.owner, BandOwnerKind.foreground); + }); + + test( + 'headless cannot steal ownership while foreground lease is held', + () async { + final foreground = await BandOwnership.acquireForeground(); + + BandOwnership.markForegroundIntent(false); + final headless = BandOwnership.tryAcquireHeadless(); + + expect(headless, isNull); + expect(BandOwnership.owner, BandOwnerKind.foreground); + BandOwnership.release(foreground); + expect(BandOwnership.owner, isNull); + }, + ); +} diff --git a/test/ble_engine_test.dart b/test/ble_engine_test.dart new file mode 100644 index 00000000..86ee7ff8 --- /dev/null +++ b/test/ble_engine_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_engine.dart'; + +void main() { + group('historical burst packet accounting', () { + test('counts ordinary historical revisions and extended revisions', () { + final count = countHistoricalBurstPackets( + dataPacketCountsByRevision: const {24: 30, 10: 5}, + revision16Count: 2, + revision19Count: 3, + revision22Count: 4, + revision25Count: 6, + revision26Count: 7, + ); + expect(count, 57); + }); + + test('traffic count includes historical packets plus side traffic', () { + final historical = countHistoricalBurstPackets( + dataPacketCountsByRevision: const {24: 30}, + ); + final traffic = countBurstTrafficPackets( + dataPacketCountsByRevision: const {24: 30}, + consoleCount: 17, + eventCount: 5, + unknownCount: 2, + ); + + expect(historical, 30); + expect(traffic, 54); + expect(historical, isNot(traffic)); + }); + + test( + 'whoop history-end expected count matches transport-envelope traffic, not just stored historical records', + () { + final historical = countHistoricalBurstPackets( + dataPacketCountsByRevision: const {24: 30}, + ); + final traffic = countBurstTrafficPackets( + dataPacketCountsByRevision: const {24: 30}, + consoleCount: 17, + eventCount: 2, + ); + + expect(historical, 30); + expect(traffic, 49); + expect(traffic, isNot(historical)); + }, + ); + + test( + 'log-shaped burst from device validates on traffic count even when only a subset are persisted historical rows', + () { + final historical = countHistoricalBurstPackets( + dataPacketCountsByRevision: const {24: 15}, + ); + final traffic = countBurstTrafficPackets( + dataPacketCountsByRevision: const {24: 15}, + eventCount: 2, + consoleCount: 37, + ); + + expect(historical, 15); + expect(traffic, 54); + }, + ); + }); + + group('history-end settle streak', () { + test('resets while queue is not empty', () { + final streak = nextBurstStablePollStreak( + queueEmpty: false, + currentCount: 79, + previousCount: 79, + stableStreak: 2, + ); + + expect(streak, 0); + }); + + test('increments only when queue is empty and count is unchanged', () { + final streak = nextBurstStablePollStreak( + queueEmpty: true, + currentCount: 79, + previousCount: 79, + stableStreak: 1, + ); + + expect(streak, 2); + }); + + test('resets when traffic count changes between polls', () { + final streak = nextBurstStablePollStreak( + queueEmpty: true, + currentCount: 80, + previousCount: 79, + stableStreak: 2, + ); + + expect(streak, 0); + }); + }); + + group('maintenance traffic gating', () { + test('maintenance traffic is paused while offload is active', () { + expect(shouldPauseMaintenanceTraffic(offloadActive: true), isTrue); + }); + + test('maintenance traffic runs when offload is inactive', () { + expect(shouldPauseMaintenanceTraffic(offloadActive: false), isFalse); + }); + }); +} diff --git a/test/ble_state_test.dart b/test/ble_state_test.dart index b32c150e..696fd221 100644 --- a/test/ble_state_test.dart +++ b/test/ble_state_test.dart @@ -124,12 +124,11 @@ void main() { bool complete = false, bool linkDown = false, int sinceStartS = 1, - }) => - e.evaluate( - complete: complete, - linkDown: linkDown, - sinceStart: Duration(seconds: sinceStartS), - ); + }) => e.evaluate( + complete: complete, + linkDown: linkDown, + sinceStart: Duration(seconds: sinceStartS), + ); test('keeps going while the offload is still streaming', () { // The KEY behaviour change: a still-running offload never stops on its own — @@ -164,49 +163,105 @@ void main() { group('DeriveDebouncer coalesce logic', () { const d = DeriveDebouncer( - quietPeriod: Duration(seconds: 12), - maxWait: Duration(seconds: 90), + staleQuietPeriod: Duration(seconds: 12), + staleMaxWait: Duration(seconds: 90), + freshQuietPeriod: Duration(minutes: 1), + freshMaxWait: Duration(minutes: 5), + staleThreshold: Duration(minutes: 30), ); test('never derives with nothing pending', () { expect( - d.shouldDerive( - hasPending: false, - sinceLastRecord: const Duration(seconds: 30), - sinceFirstPending: const Duration(seconds: 30), - ), - isFalse); + d.shouldDerive( + hasPending: false, + sinceLastRecord: const Duration(seconds: 30), + sinceFirstPending: const Duration(seconds: 30), + dataStaleness: const Duration(hours: 2), + ), + isFalse, + ); }); test('holds while records are still arriving (not yet quiet)', () { expect( - d.shouldDerive( - hasPending: true, - sinceLastRecord: const Duration(seconds: 3), - sinceFirstPending: const Duration(seconds: 5), - ), - isFalse); + d.shouldDerive( + hasPending: true, + sinceLastRecord: const Duration(seconds: 3), + sinceFirstPending: const Duration(seconds: 5), + dataStaleness: const Duration(hours: 2), + ), + isFalse, + ); }); - test('fires once the inbound stream goes quiet', () { + test('stale mode fires once the inbound stream goes quiet', () { expect( - d.shouldDerive( - hasPending: true, - sinceLastRecord: const Duration(seconds: 12), - sinceFirstPending: const Duration(seconds: 20), - ), - isTrue); + d.shouldDerive( + hasPending: true, + sinceLastRecord: const Duration(seconds: 12), + sinceFirstPending: const Duration(seconds: 20), + dataStaleness: const Duration(hours: 2), + ), + isTrue, + ); }); - test('never-quiet stream still derives at the maxWait floor', () { + test('stale mode never-quiet stream still derives at the maxWait floor', () { // Records keep landing (only 2s quiet) but the dirty run is 90s old → derive. expect( + d.shouldDerive( + hasPending: true, + sinceLastRecord: const Duration(seconds: 2), + sinceFirstPending: const Duration(seconds: 90), + dataStaleness: const Duration(hours: 2), + ), + isTrue, + ); + }); + + test('fresh mode waits longer before deriving', () { + expect( + d.shouldDerive( + hasPending: true, + sinceLastRecord: const Duration(seconds: 20), + sinceFirstPending: const Duration(seconds: 90), + dataStaleness: const Duration(minutes: 5), + ), + isFalse, + ); + expect( + d.shouldDerive( + hasPending: true, + sinceLastRecord: const Duration(minutes: 1), + sinceFirstPending: const Duration(minutes: 2), + dataStaleness: const Duration(minutes: 5), + ), + isTrue, + ); + }); + + test( + 'fresh mode never-quiet stream derives at the calmer 5 minute floor', + () { + expect( d.shouldDerive( hasPending: true, sinceLastRecord: const Duration(seconds: 2), - sinceFirstPending: const Duration(seconds: 90), + sinceFirstPending: const Duration(minutes: 4, seconds: 59), + dataStaleness: const Duration(minutes: 5), ), - isTrue); - }); + isFalse, + ); + expect( + d.shouldDerive( + hasPending: true, + sinceLastRecord: const Duration(seconds: 2), + sinceFirstPending: const Duration(minutes: 5), + dataStaleness: const Duration(minutes: 5), + ), + isTrue, + ); + }, + ); }); } diff --git a/test/derivation_scope_test.dart b/test/derivation_scope_test.dart new file mode 100644 index 00000000..780f2d3f --- /dev/null +++ b/test/derivation_scope_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/derivation_engine.dart'; + +void main() { + group('selectLightDeriveDays', () { + test( + 'prioritizes today when raw has reached today and today is pending', + () { + final light = selectLightDeriveDays( + rawDays: const {'2026-07-01', '2026-07-02'}, + pendingDays: const ['2026-07-01', '2026-07-02'], + today: '2026-07-02', + ); + + expect(light.days, ['2026-07-02']); + expect(light.reason, 'today-priority'); + }, + ); + + test('falls back to latest pending day when today has no raw yet', () { + final light = selectLightDeriveDays( + rawDays: const {'2026-07-01'}, + pendingDays: const ['2026-06-30', '2026-07-01'], + today: '2026-07-02', + ); + + expect(light.days, ['2026-07-01']); + expect(light.reason, 'latest-pending'); + }); + + test( + 'falls back to latest pending day when today raw exists but today is finalized', + () { + final light = selectLightDeriveDays( + rawDays: const {'2026-07-01', '2026-07-02'}, + pendingDays: const ['2026-07-01'], + today: '2026-07-02', + ); + + expect(light.days, ['2026-07-01']); + expect(light.reason, 'latest-pending'); + }, + ); + }); +} diff --git a/test/high_freq_wake_window_test.dart b/test/high_freq_wake_window_test.dart new file mode 100644 index 00000000..362f9c49 --- /dev/null +++ b/test/high_freq_wake_window_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/sync/high_freq_wake_window.dart'; + +void main() { + Map rowForWake(DateTime wake) => { + 'window_json': '{"value":{"offset_ms":${wake.millisecondsSinceEpoch}}}', + }; + + test('enables inside the 90 minute wake window from habitual wake', () { + final rows = [ + rowForWake(DateTime(2026, 6, 28, 7, 30)), + rowForWake(DateTime(2026, 6, 27, 7, 28)), + rowForWake(DateTime(2026, 6, 26, 7, 31)), + ]; + final now = DateTime(2026, 6, 29, 6, 45); + final plan = HighFreqWakeWindow.planFromRows(rows, now); + expect(plan.shouldEnable, isTrue); + expect(plan.source, 'habitual_wake'); + expect(plan.targetWake, DateTime(2026, 6, 29, 7, 30)); + }); + + test('disables outside the wake window', () { + final rows = [ + rowForWake(DateTime(2026, 6, 28, 7, 30)), + rowForWake(DateTime(2026, 6, 27, 7, 29)), + rowForWake(DateTime(2026, 6, 26, 7, 31)), + ]; + final now = DateTime(2026, 6, 29, 4, 0); + final plan = HighFreqWakeWindow.planFromRows(rows, now); + expect(plan.shouldEnable, isFalse); + expect(plan.targetWake, DateTime(2026, 6, 29, 7, 30)); + }); + + test('requires enough sleep history', () { + final rows = [ + rowForWake(DateTime(2026, 6, 28, 7, 30)), + rowForWake(DateTime(2026, 6, 27, 7, 29)), + ]; + final plan = HighFreqWakeWindow.planFromRows( + rows, + DateTime(2026, 6, 29, 6, 45), + ); + expect(plan.shouldEnable, isFalse); + expect(plan.targetWake, isNull); + expect(plan.source, 'insufficient_sleep_history'); + }); +} diff --git a/test/local_persistence_test.dart b/test/local_persistence_test.dart index 5d6c5a03..e0c48eff 100644 --- a/test/local_persistence_test.dart +++ b/test/local_persistence_test.dart @@ -148,15 +148,27 @@ void main() { () async { const dayId = '2099-12-31'; final rawTs = DateTime(2099, 12, 31, 12).millisecondsSinceEpoch ~/ 1000; - final db = await LocalDb.instance; - await db.insert('raw_records', { - 'counter': 900001, - 'hex': 'deadbeef', - 'packet_type': 47, - 'captured_at': rawTs * 1000, - 'rec_ts': rawTs, - 'uploaded': 0, - }); + await LocalDb.insertRecord( + RawRecord( + counter: 900001, + packetType: 47, + hex: 'deadbeef', + capturedAt: rawTs * 1000, + recTs: rawTs, + ), + Sample( + tsEpoch: rawTs, + counter: 900001, + hr: 60, + rrIntervalsMs: const [1000], + ax: 0, + ay: 0, + az: 1, + spo2RedRaw: 1, + spo2IrRaw: 1, + skinTempRaw: 1, + ), + ); await LocalDb.putDayResult( dayId: dayId, @@ -266,11 +278,6 @@ void main() { expect(rr.first['rr_ms'], 980); expect(rr.last['rr_ms'], 1005); - final latest = await LocalDb.latestSample(); - expect(latest, isNotNull); - expect(latest!.counter, 424242); - expect(latest.tsEpoch, startSec); - final ranged = await LocalDb.samplesInRange(startSec - 1, startSec + 1); expect(ranged, hasLength(1)); expect(ranged.first.counter, 424242); @@ -418,7 +425,7 @@ void main() { }); test( - 'commitSyncBatch persists raw + advances high-water + stores trim token', + 'commitSyncBatch persists decoded rows + advances high-water + stores trim token', () async { RawRecord raw(int counter, int recTs) => RawRecord( counter: counter, diff --git a/test/screen_loader_test.dart b/test/screen_loader_test.dart new file mode 100644 index 00000000..b3bceda8 --- /dev/null +++ b/test/screen_loader_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui/widgets/screen_loader.dart'; + +void main() { + group('ScreenRefreshGate', () { + test('admits first refresh immediately', () { + final gate = ScreenRefreshGate(); + + expect(gate.tryBegin(), isTrue); + expect(gate.busy, isTrue); + expect(gate.queued, isFalse); + }); + + test('queues a refresh that arrives while busy', () { + final gate = ScreenRefreshGate(); + + expect(gate.tryBegin(), isTrue); + expect(gate.tryBegin(), isFalse); + expect(gate.busy, isTrue); + expect(gate.queued, isTrue); + }); + + test('replays exactly one queued refresh when current fetch finishes', () { + final gate = ScreenRefreshGate(); + + expect(gate.tryBegin(), isTrue); + expect(gate.tryBegin(), isFalse); + + expect(gate.finishAndShouldReplay(), isTrue); + expect(gate.busy, isFalse); + expect(gate.queued, isFalse); + + expect(gate.finishAndShouldReplay(), isFalse); + }); + }); +} diff --git a/test/sync_policy_test.dart b/test/sync_policy_test.dart index 181418f8..f17c935e 100644 --- a/test/sync_policy_test.dart +++ b/test/sync_policy_test.dart @@ -23,28 +23,51 @@ void main() { final newest = wall; // Inside the ±7d margin around the strap's own window → kept. expect( - isPlausibleUnix(oldest - 6 * 86400, wall, - sessionOldestUnix: oldest, sessionNewestUnix: newest), - isTrue); + isPlausibleUnix( + oldest - 6 * 86400, + wall, + sessionOldestUnix: oldest, + sessionNewestUnix: newest, + ), + isTrue, + ); // >7d before the oldest banked record → rejected (wandering-clock pollution). expect( - isPlausibleUnix(oldest - 8 * 86400, wall, - sessionOldestUnix: oldest, sessionNewestUnix: newest), - isFalse); + isPlausibleUnix( + oldest - 8 * 86400, + wall, + sessionOldestUnix: oldest, + sessionNewestUnix: newest, + ), + isFalse, + ); // >7d after the newest → rejected. expect( - isPlausibleUnix(newest + 8 * 86400, wall, - sessionOldestUnix: oldest, sessionNewestUnix: newest), - isFalse); + isPlausibleUnix( + newest + 8 * 86400, + wall, + sessionOldestUnix: oldest, + sessionNewestUnix: newest, + ), + isFalse, + ); }); - test('a garbage session range is ignored (falls back to absolute gate)', () { - // newest < oldest → invalid range → only the absolute gate applies. - expect( - isPlausibleUnix(wall, wall, - sessionOldestUnix: wall, sessionNewestUnix: wall - 100), - isTrue); - }); + test( + 'a garbage session range is ignored (falls back to absolute gate)', + () { + // newest < oldest → invalid range → only the absolute gate applies. + expect( + isPlausibleUnix( + wall, + wall, + sessionOldestUnix: wall, + sessionNewestUnix: wall - 100, + ), + isTrue, + ); + }, + ); }); group('ClockPolicy', () { @@ -58,36 +81,76 @@ void main() { group('BackfillPolicy', () { test('first run is always allowed', () { - expect(BackfillPolicy.shouldRun(BackfillTrigger.periodic, 100, null, 0), - isTrue); + expect( + BackfillPolicy.shouldRun(BackfillTrigger.periodic, 100, null, 0), + isTrue, + ); }); test('manual + autoContinue are never floored', () { - expect(BackfillPolicy.shouldRun(BackfillTrigger.manual, 0.1, 0, 0), isTrue); - expect(BackfillPolicy.shouldRun(BackfillTrigger.autoContinue, 0.1, 0, 0), - isTrue); + expect( + BackfillPolicy.shouldRun(BackfillTrigger.manual, 0.1, 0, 0), + isTrue, + ); + expect( + BackfillPolicy.shouldRun(BackfillTrigger.autoContinue, 0.1, 0, 0), + isTrue, + ); }); test('periodic honors the 900s floor', () { - expect(BackfillPolicy.shouldRun(BackfillTrigger.periodic, 899, 0, 0), - isFalse); expect( - BackfillPolicy.shouldRun(BackfillTrigger.periodic, 900, 0, 0), isTrue); + BackfillPolicy.shouldRun(BackfillTrigger.periodic, 899, 0, 0), + isFalse, + ); + expect( + BackfillPolicy.shouldRun(BackfillTrigger.periodic, 900, 0, 0), + isTrue, + ); }); test('connect/foreground honor the 90s event floor', () { - expect(BackfillPolicy.shouldRun(BackfillTrigger.connect, 89, 0, 0), isFalse); - expect(BackfillPolicy.shouldRun(BackfillTrigger.connect, 90, 0, 0), isTrue); + expect( + BackfillPolicy.shouldRun(BackfillTrigger.connect, 89, 0, 0), + isFalse, + ); + expect( + BackfillPolicy.shouldRun(BackfillTrigger.connect, 90, 0, 0), + isTrue, + ); }); test('empty-streak backoff multiplies the strap floor (capped 4x)', () { // streak 3 → 2^1 = 2x event floor (90 → 180s) - expect(BackfillPolicy.shouldRun(BackfillTrigger.strap, 179, 0, 3), isFalse); - expect(BackfillPolicy.shouldRun(BackfillTrigger.strap, 180, 0, 3), isTrue); + expect( + BackfillPolicy.shouldRun(BackfillTrigger.strap, 179, 0, 3), + isFalse, + ); + expect( + BackfillPolicy.shouldRun(BackfillTrigger.strap, 180, 0, 3), + isTrue, + ); // streak huge → capped at 4x (90 → 360s), not unbounded. expect( - BackfillPolicy.shouldRun(BackfillTrigger.strap, 359, 0, 99), isFalse); - expect(BackfillPolicy.shouldRun(BackfillTrigger.strap, 360, 0, 99), isTrue); + BackfillPolicy.shouldRun(BackfillTrigger.strap, 359, 0, 99), + isFalse, + ); + expect( + BackfillPolicy.shouldRun(BackfillTrigger.strap, 360, 0, 99), + isTrue, + ); + }); + }); + + group('HistoricalSyncCommandPolicy', () { + test('first historical send is immediate', () { + expect(HistoricalSyncCommandPolicy.waitSeconds(null, 100), 0); + }); + + test('historical send is floored to 5 seconds', () { + expect(HistoricalSyncCommandPolicy.waitSeconds(100, 101), 4); + expect(HistoricalSyncCommandPolicy.waitSeconds(100, 104.5), 0.5); + expect(HistoricalSyncCommandPolicy.waitSeconds(100, 105), 0); }); }); @@ -96,74 +159,98 @@ void main() { bool connected = true, int? strapNewest = 2000, int? frontier = 1000, - int rows = 50, bool trimAdvanced = true, int count = 0, - }) => - BackfillContinuation.shouldAutoContinue( - stillConnected: connected, - strapNewestTs: strapNewest, - ourFrontierTs: frontier, - rowsPersistedThisSession: rows, - lastTrimAdvanced: trimAdvanced, - consecutiveCount: count, - ); + }) => BackfillContinuation.shouldAutoContinue( + stillConnected: connected, + strapNewestTs: strapNewest, + ourFrontierTs: frontier, + lastTrimAdvanced: trimAdvanced, + consecutiveCount: count, + ); test('continues when strap is >5min ahead and trim advanced', () { expect(cont(strapNewest: 2000, frontier: 1000), isTrue); }); - test('stops when disconnected', () => expect(cont(connected: false), isFalse)); - test('stops at the per-connection cap', () => expect(cont(count: 6), isFalse)); - test('stops when the cursor did not advance (spin guard)', - () => expect(cont(trimAdvanced: false), isFalse)); - test('within the behind-gap but rows persisted → continues (#451 stale newest)', - () => expect(cont(strapNewest: 1100, frontier: 1000, rows: 30), isTrue)); - test('within the behind-gap and no rows → stops', - () => expect(cont(strapNewest: 1100, frontier: 1000, rows: 0), isFalse)); + test( + 'stops when disconnected', + () => expect(cont(connected: false), isFalse), + ); + test( + 'stops at the per-connection cap', + () => expect(cont(count: 6), isFalse), + ); + test( + 'stops when the cursor did not advance (spin guard)', + () => expect(cont(trimAdvanced: false), isFalse), + ); + test( + 'within the behind-gap and no rows → stops', + () => expect(cont(strapNewest: 1100, frontier: 1000), isFalse), + ); + test( + 'missing strap newest information → stops', + () => expect(cont(strapNewest: null, frontier: 1000), isFalse), + ); }); group('MarginalRadioDetector', () { test('trips after 2 consecutive arm→quick-timeouts, one-shot', () { final d = MarginalRadioDetector(); expect( - d.connectionEnded( - wasArmed: true, secondsSinceArm: 5, timedOut: true), - isFalse); + d.connectionEnded(wasArmed: true, secondsSinceArm: 5, timedOut: true), + isFalse, + ); expect( - d.connectionEnded( - wasArmed: true, secondsSinceArm: 5, timedOut: true), - isTrue); // trips + d.connectionEnded(wasArmed: true, secondsSinceArm: 5, timedOut: true), + isTrue, + ); // trips expect( - d.connectionEnded( - wasArmed: true, secondsSinceArm: 5, timedOut: true), - isFalse); // already tripped → one-shot + d.connectionEnded(wasArmed: true, secondsSinceArm: 5, timedOut: true), + isFalse, + ); // already tripped → one-shot }); - test('a slow timeout (>20s after arm) does not count + resets the streak', () { - final d = MarginalRadioDetector(); - d.connectionEnded(wasArmed: true, secondsSinceArm: 5, timedOut: true); - // 25s later → outside the quick window → resets. - expect( - d.connectionEnded( - wasArmed: true, secondsSinceArm: 25, timedOut: true), - isFalse); - // Next single quick timeout shouldn't trip (streak was reset). - expect( + test( + 'a slow timeout (>20s after arm) does not count + resets the streak', + () { + final d = MarginalRadioDetector(); + d.connectionEnded(wasArmed: true, secondsSinceArm: 5, timedOut: true); + // 25s later → outside the quick window → resets. + expect( d.connectionEnded( - wasArmed: true, secondsSinceArm: 5, timedOut: true), - isFalse); - }); + wasArmed: true, + secondsSinceArm: 25, + timedOut: true, + ), + isFalse, + ); + // Next single quick timeout shouldn't trip (streak was reset). + expect( + d.connectionEnded(wasArmed: true, secondsSinceArm: 5, timedOut: true), + isFalse, + ); + }, + ); test('not armed → never counts', () { final d = MarginalRadioDetector(); expect( - d.connectionEnded( - wasArmed: false, secondsSinceArm: null, timedOut: true), - isFalse); + d.connectionEnded( + wasArmed: false, + secondsSinceArm: null, + timedOut: true, + ), + isFalse, + ); expect( - d.connectionEnded( - wasArmed: false, secondsSinceArm: null, timedOut: true), - isFalse); + d.connectionEnded( + wasArmed: false, + secondsSinceArm: null, + timedOut: true, + ), + isFalse, + ); }); }); @@ -171,21 +258,21 @@ void main() { test('trips after 2 bond→quick(<=8s)-timeouts', () { final d = PostBondTimeoutLoopDetector(); expect( - d.connectionEnded( - wasBonded: true, secondsSinceBond: 2, timedOut: true), - isFalse); + d.connectionEnded(wasBonded: true, secondsSinceBond: 2, timedOut: true), + isFalse, + ); expect( - d.connectionEnded( - wasBonded: true, secondsSinceBond: 2, timedOut: true), - isTrue); + d.connectionEnded(wasBonded: true, secondsSinceBond: 2, timedOut: true), + isTrue, + ); }); test('a timeout 9s after bond is outside the window', () { final d = PostBondTimeoutLoopDetector(); d.connectionEnded(wasBonded: true, secondsSinceBond: 2, timedOut: true); expect( - d.connectionEnded( - wasBonded: true, secondsSinceBond: 9, timedOut: true), - isFalse); + d.connectionEnded(wasBonded: true, secondsSinceBond: 9, timedOut: true), + isFalse, + ); }); }); @@ -193,25 +280,30 @@ void main() { test('trips on the 3rd consecutive console-only completed sync', () { final d = EmptySyncTracker(); expect( - d.recordCompletedSync(bankedSensorRecords: false, consoleOnly: true), - isFalse); + d.recordCompletedSync(bankedSensorRecords: false, consoleOnly: true), + isFalse, + ); expect( - d.recordCompletedSync(bankedSensorRecords: false, consoleOnly: true), - isFalse); + d.recordCompletedSync(bankedSensorRecords: false, consoleOnly: true), + isFalse, + ); expect( - d.recordCompletedSync(bankedSensorRecords: false, consoleOnly: true), - isTrue); + d.recordCompletedSync(bankedSensorRecords: false, consoleOnly: true), + isTrue, + ); }); test('a sync that banked sensor records resets the streak', () { final d = EmptySyncTracker(); d.recordCompletedSync(bankedSensorRecords: false, consoleOnly: true); d.recordCompletedSync(bankedSensorRecords: false, consoleOnly: true); expect( - d.recordCompletedSync(bankedSensorRecords: true, consoleOnly: false), - isFalse); // reset + d.recordCompletedSync(bankedSensorRecords: true, consoleOnly: false), + isFalse, + ); // reset expect( - d.recordCompletedSync(bankedSensorRecords: false, consoleOnly: true), - isFalse); // streak back to 1 + d.recordCompletedSync(bankedSensorRecords: false, consoleOnly: true), + isFalse, + ); // streak back to 1 }); }); diff --git a/tool/sleep_probe.dart b/tool/sleep_probe.dart index be9ca58c..ea61f361 100644 --- a/tool/sleep_probe.dart +++ b/tool/sleep_probe.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print + // Real-data sleep probe — NOT shipped. Decodes a dumped R24 hex file into the // real Substrate, runs the EXACT pipeline detectors, and prints, per night: // • van Hees window (ACCEL ONLY) — onset/offset/SPT @@ -7,7 +9,6 @@ // so we can see whether van Hees fragmentation or the HR-dip trim is what shoves // onset to 02:41. Run: dart run tool/sleep_probe.dart [/tmp/r24_0626_hex.txt] import 'dart:io'; -import 'dart:math' as math; import 'package:openstrap_edge/compute/substrate.dart'; import 'package:openstrap_analytics/onehz.dart' as ana;