diff --git a/lib/core/datalog/datalog_db.dart b/lib/core/datalog/datalog_db.dart new file mode 100644 index 0000000..1477600 --- /dev/null +++ b/lib/core/datalog/datalog_db.dart @@ -0,0 +1,140 @@ +import 'dart:typed_data'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart'; + +import '../models/datalog_session.dart'; + +/// SQLite database wrapper for sessions and raw ECU frames. +class DatalogDb { + static const _dbName = 'hvbt_datalog.db'; + static const _version = 1; + + Database? _db; + + Future get _database async { + _db ??= await _open(); + return _db!; + } + + Future _open() async { + final dir = await getApplicationDocumentsDirectory(); + final path = p.join(dir.path, _dbName); + return openDatabase( + path, + version: _version, + onCreate: (db, _) async { + await db.execute(''' + CREATE TABLE sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ecu_type TEXT NOT NULL, + start_time INTEGER NOT NULL, + end_time INTEGER, + frame_count INTEGER NOT NULL DEFAULT 0 + ) + '''); + await db.execute(''' + CREATE TABLE frames ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + timestamp INTEGER NOT NULL, + data BLOB NOT NULL, + FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE + ) + '''); + await db.execute( + 'CREATE INDEX idx_frames_session ON frames(session_id)'); + }, + ); + } + + // ── Sessions ────────────────────────────────────────────────────────────── + + /// Inserts a new session row. Returns the assigned id. + Future insertSession(DatalogSession session) async { + final db = await _database; + return db.insert('sessions', session.toMap()..remove('id')); + } + + /// Updates end_time and frame_count for an existing session. + Future closeSession(int sessionId, DateTime endTime, int frameCount) async { + final db = await _database; + await db.update( + 'sessions', + { + 'end_time': endTime.millisecondsSinceEpoch, + 'frame_count': frameCount, + }, + where: 'id = ?', + whereArgs: [sessionId], + ); + } + + /// Returns all sessions ordered newest first. + Future> getSessions() async { + final db = await _database; + final rows = await db.query('sessions', orderBy: 'start_time DESC'); + return rows.map(DatalogSession.fromMap).toList(); + } + + /// Deletes a session and all its frames (CASCADE). + Future deleteSession(int sessionId) async { + final db = await _database; + await db.delete('sessions', where: 'id = ?', whereArgs: [sessionId]); + } + + // ── Frames ──────────────────────────────────────────────────────────────── + + /// Batch-inserts multiple frames in a single transaction. + Future insertFrames( + int sessionId, List frames) async { + if (frames.isEmpty) return; + final db = await _database; + await db.transaction((txn) async { + final batch = txn.batch(); + for (final f in frames) { + batch.insert('frames', { + 'session_id': sessionId, + 'timestamp': f.timestamp.millisecondsSinceEpoch, + 'data': f.data, + }); + } + await batch.commit(noResult: true); + }); + } + + /// Returns all raw frames for a session, ordered by timestamp. + Future> getFrames(int sessionId) async { + final db = await _database; + final rows = await db.query( + 'frames', + where: 'session_id = ?', + whereArgs: [sessionId], + orderBy: 'timestamp ASC', + ); + return rows + .map((r) => StoredFrame( + timestamp: DateTime.fromMillisecondsSinceEpoch( + r['timestamp'] as int), + data: r['data'] as Uint8List, + )) + .toList(); + } + + Future close() async => _db?.close(); +} + +// ─── DTOs ───────────────────────────────────────────────────────────────────── + +class PendingFrame { + final DateTime timestamp; + final Uint8List data; + const PendingFrame(this.timestamp, this.data); +} + +class StoredFrame { + final DateTime timestamp; + final Uint8List data; + const StoredFrame({required this.timestamp, required this.data}); +} diff --git a/lib/core/datalog/datalog_player.dart b/lib/core/datalog/datalog_player.dart new file mode 100644 index 0000000..5207236 --- /dev/null +++ b/lib/core/datalog/datalog_player.dart @@ -0,0 +1,119 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'datalog_db.dart'; + +enum PlaybackStatus { idle, playing, paused } + +/// Loads stored frames from SQLite and replays them through a stream +/// at configurable speed. Consumers watch [frameStream] exactly like +/// the live BT stream — the parser layer is unaware of the difference. +class DatalogPlayer { + final DatalogDb _db; + + List _frames = []; + int _position = 0; // index into _frames + double _speed = 1.0; // 1.0 = real-time, 2.0 = 2× faster + + PlaybackStatus _status = PlaybackStatus.idle; + PlaybackStatus get status => _status; + int get position => _position; + int get totalFrames => _frames.length; + double get speed => _speed; + set speed(double v) => _speed = v.clamp(0.25, 8.0); + + final StreamController _controller = + StreamController.broadcast(); + + Stream get frameStream => _controller.stream; + + /// Callback fired on each tick — lets UI update a progress indicator. + void Function(int position, int total)? onProgress; + + Timer? _timer; + + DatalogPlayer(this._db); + + /// Load all frames for [sessionId] into memory. + Future load(int sessionId) async { + await stop(); + _frames = await _db.getFrames(sessionId); + _position = 0; + _status = PlaybackStatus.idle; + } + + /// Start or resume playback. + void play() { + if (_status == PlaybackStatus.playing) return; + if (_frames.isEmpty) return; + _status = PlaybackStatus.playing; + _scheduleNext(); + } + + void pause() { + _timer?.cancel(); + _timer = null; + if (_status == PlaybackStatus.playing) { + _status = PlaybackStatus.paused; + } + } + + Future stop() async { + _timer?.cancel(); + _timer = null; + _position = 0; + _status = PlaybackStatus.idle; + } + + /// Jump to a specific frame index. + void seek(int index) { + _position = index.clamp(0, _frames.length - 1); + onProgress?.call(_position, _frames.length); + } + + void _scheduleNext() { + if (_position >= _frames.length) { + _status = PlaybackStatus.idle; + onProgress?.call(_frames.length, _frames.length); + return; + } + + // Calculate delay to next frame using original timestamps + Duration delay = const Duration(milliseconds: 100); // default 10 Hz + if (_position + 1 < _frames.length) { + final gap = _frames[_position + 1].timestamp + .difference(_frames[_position].timestamp); + delay = Duration( + microseconds: (gap.inMicroseconds / _speed).round(), + ); + // Clamp to 10ms–2s to handle gaps from pauses/reconnects + delay = delay.clamp( + const Duration(milliseconds: 10), + const Duration(seconds: 2), + ); + } + + _timer = Timer(delay, () { + if (_status != PlaybackStatus.playing) return; + if (_position < _frames.length) { + _controller.add(_frames[_position].data); + onProgress?.call(_position, _frames.length); + _position++; + } + _scheduleNext(); + }); + } + + void dispose() { + _timer?.cancel(); + _controller.close(); + } +} + +extension on Duration { + Duration clamp(Duration min, Duration max) { + if (this < min) return min; + if (this > max) return max; + return this; + } +} diff --git a/lib/core/datalog/datalog_recorder.dart b/lib/core/datalog/datalog_recorder.dart new file mode 100644 index 0000000..1d1f0d8 --- /dev/null +++ b/lib/core/datalog/datalog_recorder.dart @@ -0,0 +1,94 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'datalog_db.dart'; +import '../models/datalog_session.dart'; + +/// Listens to a raw frame stream and batches writes to SQLite every 500ms. +class DatalogRecorder { + final DatalogDb _db; + + int? _sessionId; + String? _ecuType; + DateTime? _startTime; + int _frameCount = 0; + + final List _batch = []; + Timer? _flushTimer; + StreamSubscription? _frameSub; + + bool get isRecording => _sessionId != null; + int get frameCount => _frameCount; + int? get sessionId => _sessionId; + + DatalogRecorder(this._db); + + /// Start a new recording session, listening to [frameStream]. + Future start(Stream frameStream, String ecuType) async { + if (isRecording) await stop(); + + _ecuType = ecuType; + _startTime = DateTime.now(); + _frameCount = 0; + _batch.clear(); + + final session = DatalogSession( + ecuType: ecuType, + startTime: _startTime!, + ); + _sessionId = await _db.insertSession(session); + + // Collect frames into batch buffer + _frameSub = frameStream.listen((Uint8List frame) { + _batch.add(PendingFrame(DateTime.now(), frame)); + _frameCount++; + }); + + // Flush to DB every 500ms + _flushTimer = Timer.periodic(const Duration(milliseconds: 500), (_) { + _flush(); + }); + } + + /// Stop recording and finalise the session row. + Future stop() async { + if (!isRecording) return null; + + _flushTimer?.cancel(); + _flushTimer = null; + await _frameSub?.cancel(); + _frameSub = null; + + await _flush(); // write any remaining buffered frames + + final endTime = DateTime.now(); + await _db.closeSession(_sessionId!, endTime, _frameCount); + + final finished = DatalogSession( + id: _sessionId, + ecuType: _ecuType!, + startTime: _startTime!, + endTime: endTime, + frameCount: _frameCount, + ); + + _sessionId = null; + _ecuType = null; + _startTime = null; + _frameCount = 0; + + return finished; + } + + Future _flush() async { + if (_batch.isEmpty || _sessionId == null) return; + final toWrite = List.from(_batch); + _batch.clear(); + await _db.insertFrames(_sessionId!, toWrite); + } + + void dispose() { + _flushTimer?.cancel(); + _frameSub?.cancel(); + } +} diff --git a/lib/core/models/datalog_session.dart b/lib/core/models/datalog_session.dart new file mode 100644 index 0000000..3da0c49 --- /dev/null +++ b/lib/core/models/datalog_session.dart @@ -0,0 +1,62 @@ +/// Metadata for a single recorded ECU datalog session. +class DatalogSession { + final int? id; + final String ecuType; + final DateTime startTime; + final DateTime? endTime; + final int frameCount; + + const DatalogSession({ + this.id, + required this.ecuType, + required this.startTime, + this.endTime, + this.frameCount = 0, + }); + + Duration get duration { + final end = endTime ?? DateTime.now(); + return end.difference(startTime); + } + + String get durationLabel { + final d = duration; + final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$m:$s'; + } + + DatalogSession copyWith({ + int? id, + String? ecuType, + DateTime? startTime, + DateTime? endTime, + int? frameCount, + }) => + DatalogSession( + id: id ?? this.id, + ecuType: ecuType ?? this.ecuType, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + frameCount: frameCount ?? this.frameCount, + ); + + Map toMap() => { + 'id': id, + 'ecu_type': ecuType, + 'start_time': startTime.millisecondsSinceEpoch, + 'end_time': endTime?.millisecondsSinceEpoch, + 'frame_count': frameCount, + }; + + factory DatalogSession.fromMap(Map m) => DatalogSession( + id: m['id'] as int?, + ecuType: m['ecu_type'] as String, + startTime: + DateTime.fromMillisecondsSinceEpoch(m['start_time'] as int), + endTime: m['end_time'] == null + ? null + : DateTime.fromMillisecondsSinceEpoch(m['end_time'] as int), + frameCount: m['frame_count'] as int? ?? 0, + ); +} diff --git a/lib/core/providers/datalog_provider.dart b/lib/core/providers/datalog_provider.dart new file mode 100644 index 0000000..da9e592 --- /dev/null +++ b/lib/core/providers/datalog_provider.dart @@ -0,0 +1,206 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../datalog/datalog_db.dart'; +import '../datalog/datalog_player.dart'; +import '../datalog/datalog_recorder.dart'; +import '../models/datalog_session.dart'; +import 'bt_provider.dart'; +import 'settings_provider.dart'; + +// ─── Singletons ─────────────────────────────────────────────────────────────── + +final datalogDbProvider = Provider((ref) { + final db = DatalogDb(); + ref.onDispose(db.close); + return db; +}); + +final datalogRecorderProvider = Provider((ref) { + final recorder = DatalogRecorder(ref.watch(datalogDbProvider)); + ref.onDispose(recorder.dispose); + return recorder; +}); + +final datalogPlayerProvider = Provider((ref) { + final player = DatalogPlayer(ref.watch(datalogDbProvider)); + ref.onDispose(player.dispose); + return player; +}); + +// ─── Recording state ────────────────────────────────────────────────────────── + +enum RecordingStatus { idle, recording } + +class RecordingState { + final RecordingStatus status; + final int frameCount; + final int? sessionId; + + const RecordingState({ + this.status = RecordingStatus.idle, + this.frameCount = 0, + this.sessionId, + }); + + bool get isRecording => status == RecordingStatus.recording; + + RecordingState copyWith({ + RecordingStatus? status, + int? frameCount, + int? sessionId, + }) => + RecordingState( + status: status ?? this.status, + frameCount: frameCount ?? this.frameCount, + sessionId: sessionId ?? this.sessionId, + ); +} + +class RecordingNotifier extends StateNotifier { + final Ref _ref; + + RecordingNotifier(this._ref) : super(const RecordingState()); + + Future startRecording() async { + if (state.isRecording) return; + + final recorder = _ref.read(datalogRecorderProvider); + final btNotifier = _ref.read(btProvider.notifier); + final ecuType = _ref.read(settingsProvider).ecuType.name; + + await recorder.start(btNotifier.frameStream, ecuType); + + state = RecordingState( + status: RecordingStatus.recording, + sessionId: recorder.sessionId, + ); + } + + Future stopRecording() async { + if (!state.isRecording) return null; + + final recorder = _ref.read(datalogRecorderProvider); + final session = await recorder.stop(); + + state = const RecordingState(); + // Refresh session list + _ref.invalidate(sessionListProvider); + return session; + } + + /// Called periodically from UI to update live frame count display. + void syncFrameCount() { + if (!state.isRecording) return; + final count = _ref.read(datalogRecorderProvider).frameCount; + if (count != state.frameCount) { + state = state.copyWith(frameCount: count); + } + } +} + +final recordingProvider = + StateNotifierProvider( + (ref) => RecordingNotifier(ref), +); + +// ─── Session list ───────────────────────────────────────────────────────────── + +final sessionListProvider = FutureProvider>((ref) { + return ref.watch(datalogDbProvider).getSessions(); +}); + +// ─── Playback state ─────────────────────────────────────────────────────────── + +class PlaybackState { + final bool isActive; + final int? sessionId; + final int position; + final int total; + final double speed; + final PlaybackStatus status; + + const PlaybackState({ + this.isActive = false, + this.sessionId, + this.position = 0, + this.total = 0, + this.speed = 1.0, + this.status = PlaybackStatus.idle, + }); + + double get progress => total == 0 ? 0 : position / total; + + PlaybackState copyWith({ + bool? isActive, + int? sessionId, + int? position, + int? total, + double? speed, + PlaybackStatus? status, + }) => + PlaybackState( + isActive: isActive ?? this.isActive, + sessionId: sessionId ?? this.sessionId, + position: position ?? this.position, + total: total ?? this.total, + speed: speed ?? this.speed, + status: status ?? this.status, + ); +} + +class PlaybackNotifier extends StateNotifier { + final Ref _ref; + + PlaybackNotifier(this._ref) : super(const PlaybackState()); + + Future loadSession(int sessionId) async { + final player = _ref.read(datalogPlayerProvider); + await player.load(sessionId); + + player.onProgress = (pos, total) { + state = state.copyWith( + position: pos, + total: total, + status: player.status, + ); + }; + + state = PlaybackState( + isActive: true, + sessionId: sessionId, + total: player.totalFrames, + speed: player.speed, + status: PlaybackStatus.idle, + ); + } + + void play() { + _ref.read(datalogPlayerProvider).play(); + state = state.copyWith(status: PlaybackStatus.playing); + } + + void pause() { + _ref.read(datalogPlayerProvider).pause(); + state = state.copyWith(status: PlaybackStatus.paused); + } + + Future stop() async { + await _ref.read(datalogPlayerProvider).stop(); + state = const PlaybackState(); + } + + void seek(int index) { + _ref.read(datalogPlayerProvider).seek(index); + state = state.copyWith(position: index); + } + + void setSpeed(double speed) { + _ref.read(datalogPlayerProvider).speed = speed; + state = state.copyWith(speed: speed); + } +} + +final playbackProvider = + StateNotifierProvider( + (ref) => PlaybackNotifier(ref), +); diff --git a/pubspec.lock b/pubspec.lock index 89e2610..5b3c018 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -273,7 +273,7 @@ packages: source: hosted version: "2.2.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" diff --git a/pubspec.yaml b/pubspec.yaml index a8eda0d..806c1a2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: HV BT Automotive ECU Dashboard publish_to: 'none' -version: 1.2.0+3 +version: 1.3.0+4 environment: sdk: '>=3.0.0 <4.0.0' @@ -18,6 +18,7 @@ dependencies: shared_preferences: ^2.2.0 path_provider: ^2.1.0 intl: ^0.19.0 + path: ^1.9.0 permission_handler: ^11.0.0 dev_dependencies: