diff --git a/lib/features/universal_import/data/models/import_enums.dart b/lib/features/universal_import/data/models/import_enums.dart index bb4bf35d9..9b2e8fdf0 100644 --- a/lib/features/universal_import/data/models/import_enums.dart +++ b/lib/features/universal_import/data/models/import_enums.dart @@ -30,7 +30,7 @@ enum ImportFormat { /// Whether this format has a parser implemented in v1.5. bool get isSupported => switch (this) { - csv || uddf || subsurfaceXml || fit => true, + csv || uddf || subsurfaceXml || fit || shearwaterDb => true, _ => false, }; } @@ -67,9 +67,7 @@ enum SourceApp { /// Instructions for exporting from this app in a supported format. String? get exportInstructions => switch (this) { - shearwater => - 'In Shearwater Cloud Desktop, go to File > Export > UDDF to create a ' - 'file that Submersion can import.', + shearwater => null, // Native .db import supported suunto => 'In Suunto DM5, select your dives and go to File > Export > UDDF.', scubapro => diff --git a/lib/features/universal_import/data/parsers/placeholder_parser.dart b/lib/features/universal_import/data/parsers/placeholder_parser.dart index d2a87acf5..4116dc1bd 100644 --- a/lib/features/universal_import/data/parsers/placeholder_parser.dart +++ b/lib/features/universal_import/data/parsers/placeholder_parser.dart @@ -19,7 +19,6 @@ class PlaceholderParser implements ImportParser { ImportFormat.divingLogXml, ImportFormat.suuntoSml, ImportFormat.suuntoDm5, - ImportFormat.shearwaterDb, ImportFormat.scubapro, ImportFormat.danDl7, ImportFormat.sqlite, diff --git a/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart b/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart new file mode 100644 index 000000000..5527710a6 --- /dev/null +++ b/lib/features/universal_import/data/parsers/shearwater_cloud_parser.dart @@ -0,0 +1,125 @@ +import 'package:flutter/services.dart'; + +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_options.dart'; +import 'package:submersion/features/universal_import/data/models/import_payload.dart'; +import 'package:submersion/features/universal_import/data/models/import_warning.dart'; +import 'package:submersion/features/universal_import/data/parsers/import_parser.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_db_reader.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_dive_mapper.dart'; + +/// Parses a Shearwater Cloud SQLite database file into an [ImportPayload]. +/// +/// Orchestrates the full Shearwater Cloud import flow: +/// 1. Validates the database structure. +/// 2. Reads raw dives from [ShearwaterDbReader]. +/// 3. Maps each dive via [ShearwaterDiveMapper.mapDive]. +/// 4. Extracts unique sites via [ShearwaterDiveMapper.mapSites]. +/// 5. Returns a unified [ImportPayload]. +class ShearwaterCloudParser implements ImportParser { + @override + List get supportedFormats => [ImportFormat.shearwaterDb]; + + @override + Future parse( + Uint8List fileBytes, { + ImportOptions? options, + }) async { + // 1. Validate database. + final isValid = await ShearwaterDbReader.isShearwaterCloudDb(fileBytes); + if (!isValid) { + return const ImportPayload( + entities: {}, + warnings: [ + ImportWarning( + severity: ImportWarningSeverity.error, + message: + 'File is not a valid Shearwater Cloud database. ' + 'Expected dive_details and log_data tables.', + ), + ], + ); + } + + // 2. Read raw dives. + final List rawDives; + try { + rawDives = await ShearwaterDbReader.readDives(fileBytes); + } catch (e) { + return ImportPayload( + entities: const {}, + warnings: [ + ImportWarning( + severity: ImportWarningSeverity.error, + message: 'Failed to read dives from database: $e', + ), + ], + ); + } + if (rawDives.isEmpty) { + return const ImportPayload( + entities: {}, + warnings: [ + ImportWarning( + severity: ImportWarningSeverity.info, + message: 'Shearwater Cloud database contains no dives.', + ), + ], + ); + } + + // 3. Map dives to ImportPayload entities. + // After the first platform-level FFI failure, fall back to + // metadata-only for remaining dives. + final warnings = []; + final diveEntities = >[]; + var ffiAvailable = true; + + for (final rawDive in rawDives) { + if (ffiAvailable) { + try { + final diveMap = await ShearwaterDiveMapper.mapDive( + rawDive, + warnings: warnings, + ); + diveEntities.add(diveMap); + } on MissingPluginException { + ffiAvailable = false; + diveEntities.add(ShearwaterDiveMapper.mapDiveMetadata(rawDive)); + } on PlatformException { + ffiAvailable = false; + warnings.add( + const ImportWarning( + severity: ImportWarningSeverity.info, + message: + 'Profile parsing is not available on this platform. ' + 'Dives will be imported with metadata only.', + entityType: ImportEntityType.dives, + ), + ); + diveEntities.add(ShearwaterDiveMapper.mapDiveMetadata(rawDive)); + } + } else { + diveEntities.add(ShearwaterDiveMapper.mapDiveMetadata(rawDive)); + } + } + + // 4. Extract unique sites. + final sites = ShearwaterDiveMapper.mapSites(rawDives); + + // 5. Build payload. + final entities = >>{}; + if (diveEntities.isNotEmpty) { + entities[ImportEntityType.dives] = diveEntities; + } + if (sites.isNotEmpty) { + entities[ImportEntityType.sites] = sites; + } + + return ImportPayload( + entities: entities, + warnings: warnings, + metadata: {'source': 'shearwater_cloud', 'diveCount': rawDives.length}, + ); + } +} diff --git a/lib/features/universal_import/data/services/shearwater_db_reader.dart b/lib/features/universal_import/data/services/shearwater_db_reader.dart new file mode 100644 index 000000000..06e261fce --- /dev/null +++ b/lib/features/universal_import/data/services/shearwater_db_reader.dart @@ -0,0 +1,362 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:sqlite3/sqlite3.dart'; + +/// Raw dive data read directly from a Shearwater Cloud SQLite database. +/// +/// Fields map 1:1 to columns from the dive_details and log_data tables. +/// Binary log data is decompressed; JSON string columns are pre-parsed. +/// Empty strings from the database are normalized to null. +class ShearwaterRawDive { + final String diveId; + final String? diveDate; + final double? depth; + final double? averageDepth; + final int? diveLengthTime; + final String? diveNumber; + final String? serialNumber; + final String? location; + final String? site; + final String? buddy; + final String? notes; + final String? environment; + final String? visibility; + final String? weather; + final String? conditions; + final String? airTemperature; + final String? weight; + final String? dress; + final String? apparatus; + final String? thermalComfort; + final String? workload; + final String? problems; + final String? malfunctions; + final String? symptoms; + final String? gnssEntryLocation; + final String? gnssExitLocation; + final String? gasNotes; + final String? gearNotes; + final String? issueNotes; + final double? endGF99; + final String? fileName; + final Uint8List? decompressedLogData; + final Map? tankProfileData; + final Map? calculatedValues; + final Map? headerJson; + final Map? footerJson; + + const ShearwaterRawDive({ + required this.diveId, + this.diveDate, + this.depth, + this.averageDepth, + this.diveLengthTime, + this.diveNumber, + this.serialNumber, + this.location, + this.site, + this.buddy, + this.notes, + this.environment, + this.visibility, + this.weather, + this.conditions, + this.airTemperature, + this.weight, + this.dress, + this.apparatus, + this.thermalComfort, + this.workload, + this.problems, + this.malfunctions, + this.symptoms, + this.gnssEntryLocation, + this.gnssExitLocation, + this.gasNotes, + this.gearNotes, + this.issueNotes, + this.endGF99, + this.fileName, + this.decompressedLogData, + this.tankProfileData, + this.calculatedValues, + this.headerJson, + this.footerJson, + }); +} + +/// Reads dives from a Shearwater Cloud SQLite database. +/// +/// The Shearwater Cloud app exports its dive log as a SQLite database +/// with two primary tables: dive_details (metadata) and log_data (binary +/// dive profile data). This reader validates the database structure, +/// queries both tables, decompresses the binary BLOBs, and parses the +/// embedded JSON fields. +class ShearwaterDbReader { + static const _requiredTables = ['dive_details', 'log_data']; + + static const _query = ''' +SELECT dd.DiveId, dd.DiveDate, dd.Depth, dd.AverageDepth, + dd.DiveLengthTime, dd.DiveNumber, dd.SerialNumber, + dd.Location, dd.Site, dd.Buddy, dd.Notes, + dd.Environment, dd.Visibility, dd.Weather, dd.Conditions, + dd.AirTemperature, dd.Weight, dd.Dress, dd.Apparatus, + dd.ThermalComfort, dd.Workload, dd.Problems, + dd.Malfunctions, dd.Symptoms, + dd.GnssEntryLocation, dd.GnssExitLocation, + dd.TankProfileData, + dd.GasNotes, dd.GearNotes, dd.IssueNotes, dd.EndGF99, + ld.file_name, ld.data_bytes_1, ld.data_bytes_2, + ld.data_bytes_3, ld.calculated_values_from_samples +FROM dive_details dd +LEFT JOIN log_data ld ON dd.DiveId = ld.log_id +ORDER BY dd.DiveDate +'''; + + /// Returns true if the given bytes represent a Shearwater Cloud database. + /// + /// Writes the bytes to a temporary file and opens it as SQLite. Checks + /// for the presence of the required tables (dive_details, log_data). + /// Returns false for any non-SQLite file or database missing those tables. + static Future isShearwaterCloudDb(Uint8List bytes) async { + final tempPath = _tempPath(); + final tempFile = File(tempPath); + try { + await tempFile.writeAsBytes(bytes); + final db = sqlite3.open(tempPath, mode: OpenMode.readOnly); + try { + final tables = _listTables(db); + return _requiredTables.every((t) => tables.contains(t)); + } finally { + db.dispose(); + } + } catch (_) { + return false; + } finally { + _deleteTempFile(tempFile); + } + } + + /// Reads all dives from the Shearwater Cloud database. + /// + /// Joins dive_details with log_data, decompresses binary profile data, + /// and parses embedded JSON fields. Empty strings are normalized to null. + static Future> readDives(Uint8List bytes) async { + final tempPath = _tempPath(); + final tempFile = File(tempPath); + try { + await tempFile.writeAsBytes(bytes); + final db = sqlite3.open(tempPath, mode: OpenMode.readOnly); + try { + final rows = db.select(_query); + return rows.map(_rowToRawDive).toList(); + } finally { + db.dispose(); + } + } finally { + _deleteTempFile(tempFile); + } + } + + // ======================== Internal helpers ======================== + + static String _tempPath() { + return '${Directory.systemTemp.path}' + '/sw_import_${DateTime.now().millisecondsSinceEpoch}.db'; + } + + static void _deleteTempFile(File file) { + try { + if (file.existsSync()) file.deleteSync(); + } catch (_) { + // Best-effort cleanup; ignore errors. + } + } + + static Set _listTables(Database db) { + final rows = db.select("SELECT name FROM sqlite_master WHERE type='table'"); + return rows.map((r) => r['name'] as String).toSet(); + } + + static ShearwaterRawDive _rowToRawDive(Row row) { + return ShearwaterRawDive( + diveId: row['DiveId'].toString(), + diveDate: _str(row['DiveDate']), + depth: _double(row['Depth']), + averageDepth: _double(row['AverageDepth']), + diveLengthTime: _int(row['DiveLengthTime']), + diveNumber: _str(row['DiveNumber']), + serialNumber: _str(row['SerialNumber']), + location: _str(row['Location']), + site: _str(row['Site']), + buddy: _str(row['Buddy']), + notes: _str(row['Notes']), + environment: _str(row['Environment']), + visibility: _str(row['Visibility']), + weather: _str(row['Weather']), + conditions: _str(row['Conditions']), + airTemperature: _str(row['AirTemperature']), + weight: _str(row['Weight']), + dress: _str(row['Dress']), + apparatus: _str(row['Apparatus']), + thermalComfort: _str(row['ThermalComfort']), + workload: _str(row['Workload']), + problems: _str(row['Problems']), + malfunctions: _str(row['Malfunctions']), + symptoms: _str(row['Symptoms']), + gnssEntryLocation: _str(row['GnssEntryLocation']), + gnssExitLocation: _str(row['GnssExitLocation']), + gasNotes: _str(row['GasNotes']), + gearNotes: _str(row['GearNotes']), + issueNotes: _str(row['IssueNotes']), + endGF99: _double(row['EndGF99']), + fileName: _str(row['file_name']), + decompressedLogData: _decompressDataBytes1(row['data_bytes_1']), + headerJson: _decodeJsonBlob(row['data_bytes_2']), + footerJson: _decodeJsonBlob(row['data_bytes_3']), + tankProfileData: _decodeJsonString(row['TankProfileData']), + calculatedValues: _decodeJsonString( + row['calculated_values_from_samples'], + ), + ); + } + + /// Normalizes a value to a non-empty String or null. + static String? _str(dynamic value) { + if (value == null) return null; + final s = value.toString(); + return s.isEmpty ? null : s; + } + + static double? _double(dynamic value) { + if (value == null) return null; + if (value is num) return value.toDouble(); + return double.tryParse(value.toString()); + } + + static int? _int(dynamic value) { + if (value == null) return null; + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value.toString()); + } + + /// Decompresses data_bytes_1: skip 4-byte length prefix, then decompress. + /// + /// Shearwater Cloud stores binary dive data as: + /// [4-byte LE decompressed size] [gzip stream] + /// + /// Some Shearwater Cloud databases produce gzip streams with zeroed-out + /// CRC32/ISIZE trailers, which Dart's strict [GZipCodec] rejects. We + /// try [GZipCodec] first, then fall back to raw deflate decompression + /// (skipping the 10-byte gzip header) which ignores the trailer. + static Uint8List? _decompressDataBytes1(dynamic value) { + if (value == null) return null; + final Uint8List raw; + if (value is Uint8List) { + raw = value; + } else if (value is List) { + raw = Uint8List.fromList(value); + } else { + return null; + } + if (raw.length <= 14) return null; // 4 prefix + 10 gzip header minimum + + final gzipBytes = raw.sublist(4); + + // Fast path: standard GZipCodec (works when CRC/trailer are valid). + try { + return Uint8List.fromList(GZipCodec().decode(gzipBytes)); + } catch (_) { + // Fall through to raw deflate. + } + + // Fallback: skip gzip header and decompress as raw deflate. + // This handles streams with zeroed-out CRC32/ISIZE trailers. + try { + final deflateStart = _gzipHeaderLength(gzipBytes); + if (deflateStart == null) return null; + final deflateData = gzipBytes.sublist(deflateStart); + final decoded = ZLibDecoder(raw: true).convert(deflateData); + return Uint8List.fromList(decoded); + } catch (_) { + return null; + } + } + + /// Returns the byte offset where the deflate data starts in a gzip stream. + /// Returns null if the stream doesn't look like valid gzip. + static int? _gzipHeaderLength(Uint8List gz) { + if (gz.length < 10) return null; + if (gz[0] != 0x1F || gz[1] != 0x8B) return null; // not gzip magic + if (gz[2] != 0x08) return null; // not deflate method + + final flags = gz[3]; + var offset = 10; // minimum gzip header + + // FEXTRA + if (flags & 0x04 != 0) { + if (gz.length < offset + 2) return null; + final xlen = gz[offset] | (gz[offset + 1] << 8); + offset += 2 + xlen; + } + // FNAME + if (flags & 0x08 != 0) { + while (offset < gz.length && gz[offset] != 0) { + offset++; + } + offset++; // skip null terminator + } + // FCOMMENT + if (flags & 0x10 != 0) { + while (offset < gz.length && gz[offset] != 0) { + offset++; + } + offset++; + } + // FHCRC + if (flags & 0x02 != 0) { + offset += 2; + } + + return offset < gz.length ? offset : null; + } + + /// Decodes data_bytes_2 and data_bytes_3: UTF-8 decode the BLOB, then JSON + /// parse. + static Map? _decodeJsonBlob(dynamic value) { + if (value == null) return null; + try { + final String text; + if (value is Uint8List) { + text = utf8.decode(value); + } else if (value is List) { + text = utf8.decode(value); + } else { + text = value.toString(); + } + if (text.isEmpty) return null; + final decoded = jsonDecode(text); + if (decoded is Map) return decoded; + return null; + } catch (_) { + return null; + } + } + + /// Parses a JSON string column (TankProfileData, calculated_values_from_samples). + static Map? _decodeJsonString(dynamic value) { + if (value == null) return null; + final s = value.toString(); + if (s.isEmpty) return null; + try { + final decoded = jsonDecode(s); + if (decoded is Map) return decoded; + return null; + } catch (_) { + return null; + } + } +} diff --git a/lib/features/universal_import/data/services/shearwater_dive_mapper.dart b/lib/features/universal_import/data/services/shearwater_dive_mapper.dart new file mode 100644 index 000000000..07a3dbc30 --- /dev/null +++ b/lib/features/universal_import/data/services/shearwater_dive_mapper.dart @@ -0,0 +1,427 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; + +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_warning.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_db_reader.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_filename_parser.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_value_mapper.dart'; + +/// Converts [ShearwaterRawDive] objects into `Map` entity maps +/// matching the field conventions used by the existing import system +/// ([UddfEntityImporter] / [IncomingDiveData.fromImportMap]). +class ShearwaterDiveMapper { + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /// Produces a dive entity map from metadata only (no profile data). + /// + /// This is the fallback when FFI parsing fails or binary log data is absent. + static Map mapDiveMetadata(ShearwaterRawDive rawDive) { + final isImperial = _isImperial(rawDive); + final filenameInfo = rawDive.fileName != null + ? ShearwaterFilenameParser.parse(rawDive.fileName!) + : const ShearwaterFilenameInfo(); + + final tanks = mapTanks(rawDive); + final surfacePressure = _extractSurfacePressure(rawDive); + + return { + 'importSource': 'shearwater_cloud', + 'importId': rawDive.diveId, + 'dateTime': _parseDateTime(rawDive.diveDate), + 'maxDepth': isImperial && rawDive.depth != null + ? ShearwaterValueMapper.feetToMeters(rawDive.depth!) + : rawDive.depth, + 'avgDepth': isImperial && rawDive.averageDepth != null + ? ShearwaterValueMapper.feetToMeters(rawDive.averageDepth!) + : rawDive.averageDepth, + 'runtime': rawDive.diveLengthTime != null + ? Duration(seconds: rawDive.diveLengthTime!) + : null, + 'diveNumber': _parseInt(rawDive.diveNumber), + if (rawDive.buddy != null) 'buddyRefs': [rawDive.buddy!], + 'notes': _buildNotes(rawDive), + 'siteName': rawDive.site, + if (rawDive.site != null) + 'site': {'uddfId': rawDive.site, 'name': rawDive.site}, + 'diveComputerModel': filenameInfo.model, + 'diveComputerSerial': filenameInfo.serial, + 'waterType': ShearwaterValueMapper.mapWaterType(rawDive.environment), + 'visibility': ShearwaterValueMapper.mapVisibility( + rawDive.visibility, + isImperial: isImperial, + ), + 'cloudCover': ShearwaterValueMapper.mapCloudCover(rawDive.weather), + 'currentStrength': ShearwaterValueMapper.mapCurrentStrength( + rawDive.conditions, + ), + 'airTemp': _convertTemperature(rawDive.airTemperature, isImperial), + 'weightAmount': _convertWeight(rawDive.weight, isImperial), + 'surfacePressure': surfacePressure, + 'diveMode': _mapDiveMode(rawDive.apparatus), + 'tanks': tanks, + 'profile': const >[], + }; + } + + /// Attempts FFI parsing for profile data, falls back to metadata-only. + /// + /// If [rawDive.decompressedLogData] is available, tries to call + /// [DiveComputerHostApi().parseRawDiveData()] to get rich profile data. + /// + /// Platform-level errors ([MissingPluginException], [PlatformException] + /// with code `UNSUPPORTED`) are rethrown so the caller can detect that + /// FFI is unavailable and skip it for remaining dives. Data-level errors + /// (corrupt dive data) are caught and added to [warnings]. + static Future> mapDive( + ShearwaterRawDive rawDive, { + List? warnings, + }) async { + final baseMap = mapDiveMetadata(rawDive); + + final logData = rawDive.decompressedLogData; + if (logData == null || logData.isEmpty) { + return baseMap; + } + + final filenameInfo = rawDive.fileName != null + ? ShearwaterFilenameParser.parse(rawDive.fileName!) + : const ShearwaterFilenameInfo(); + + final vendorProduct = filenameInfo.model != null + ? ShearwaterFilenameParser.vendorProduct(filenameInfo.model!) + : null; + + if (vendorProduct == null) { + warnings?.add( + const ImportWarning( + severity: ImportWarningSeverity.warning, + message: + 'Could not determine dive computer model for profile parsing', + entityType: ImportEntityType.dives, + ), + ); + return baseMap; + } + + try { + final parsed = await _parseWithFfi( + vendor: vendorProduct.$1, + product: vendorProduct.$2, + data: logData, + ); + return mergeWithParsedDive(baseMap, parsed); + } on MissingPluginException { + rethrow; + } on PlatformException catch (e) { + if (e.code == 'UNSUPPORTED' || e.code == 'channel-error') rethrow; + warnings?.add( + ImportWarning( + severity: ImportWarningSeverity.warning, + message: 'Profile parsing failed for dive ${rawDive.diveId}: $e', + entityType: ImportEntityType.dives, + ), + ); + return baseMap; + } catch (e) { + warnings?.add( + ImportWarning( + severity: ImportWarningSeverity.warning, + message: 'Profile parsing failed for dive ${rawDive.diveId}: $e', + entityType: ImportEntityType.dives, + ), + ); + return baseMap; + } + } + + /// Parse [TankProfileData] JSON into tank entity maps. + /// + /// Only active tanks (where `DiveTransmitter.IsOn == true`) are included. + /// Pressures are converted from PSI to bar (rounded to int). + static List> mapTanks(ShearwaterRawDive rawDive) { + final tankData = + rawDive.tankProfileData?['TankData'] as List? ?? []; + if (tankData.isEmpty) return const []; + + final results = >[]; + + for (final raw in tankData) { + final tank = raw as Map; + final transmitter = tank['DiveTransmitter'] as Map?; + + if (transmitter == null || transmitter['IsOn'] != true) continue; + + final gasProfile = tank['GasProfile'] as Map?; + final o2 = _toDouble(gasProfile?['O2Percent']) ?? 21.0; + final he = _toDouble(gasProfile?['HePercent']) ?? 0.0; + + results.add({ + 'gasMix': GasMix(o2: o2, he: he), + 'startPressure': _parsePsiPressure(tank['StartPressurePSI']), + 'endPressure': _parsePsiPressure(tank['EndPressurePSI']), + 'name': transmitter['Name'] as String? ?? '', + }); + } + + return results; + } + + /// Deduplicates dive sites by name across a list of raw dives. + /// + /// Each unique site name produces one site entity map with `name`, + /// `uddfId`, and optional location fields. + static List> mapSites(List dives) { + final siteMap = >{}; + + for (final dive in dives) { + final name = dive.site; + if (name == null) continue; + + siteMap.putIfAbsent(name, () { + final site = {'name': name, 'uddfId': name}; + + if (dive.location != null) { + site['notes'] = dive.location; + } + + final coords = _parseGnssLocation(dive.gnssEntryLocation); + if (coords != null) { + site['latitude'] = coords.$1; + site['longitude'] = coords.$2; + } + + return site; + }); + } + + return siteMap.values.toList(); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + static bool _isImperial(ShearwaterRawDive rawDive) { + final unitSystem = rawDive.footerJson?['UnitSystem']; + return unitSystem == 1; + } + + static DateTime? _parseDateTime(String? dateStr) { + if (dateStr == null) return null; + final parsed = DateTime.tryParse(dateStr); + if (parsed == null) return null; + if (parsed.isUtc) return parsed; + // Normalize naive/local datetimes to UTC wall-time to match the + // convention used by other import paths (ValueTransforms.parseDate). + return DateTime.utc( + parsed.year, + parsed.month, + parsed.day, + parsed.hour, + parsed.minute, + parsed.second, + parsed.millisecond, + parsed.microsecond, + ); + } + + static int? _parseInt(String? value) { + if (value == null) return null; + return int.tryParse(value); + } + + static double? _toDouble(dynamic value) { + if (value == null) return null; + if (value is num) return value.toDouble(); + return double.tryParse(value.toString()); + } + + static double? _convertTemperature(String? value, bool isImperial) { + if (value == null) return null; + final numValue = double.tryParse(value); + if (numValue == null) return null; + return isImperial + ? ShearwaterValueMapper.fahrenheitToCelsius(numValue) + : numValue; + } + + static double? _convertWeight(String? value, bool isImperial) { + if (value == null) return null; + final numValue = double.tryParse(value); + if (numValue == null) return null; + return isImperial ? ShearwaterValueMapper.lbsToKg(numValue) : numValue; + } + + /// Parses a PSI string value and converts to bar. + /// + /// Returns null for empty or non-numeric values. + static int? _parsePsiPressure(dynamic value) { + if (value == null) return null; + final str = value.toString(); + if (str.isEmpty) return null; + final psi = double.tryParse(str); + if (psi == null) return null; + return ShearwaterValueMapper.psiToBar(psi).round(); + } + + static DiveMode _mapDiveMode(String? apparatus) { + if (apparatus == null) return DiveMode.oc; + final lower = apparatus.toLowerCase(); + if (lower.contains('closed circuit') || lower == 'ccr') return DiveMode.ccr; + if (lower.contains('semi-closed') || lower == 'scr') return DiveMode.scr; + return DiveMode.oc; + } + + static String _buildNotes(ShearwaterRawDive rawDive) { + final userNotes = rawDive.notes; + final extraNotes = ShearwaterValueMapper.buildExtraNotes( + weather: rawDive.weather, + conditions: rawDive.conditions, + dress: rawDive.dress, + thermalComfort: rawDive.thermalComfort, + workload: rawDive.workload, + problems: rawDive.problems, + malfunctions: rawDive.malfunctions, + symptoms: rawDive.symptoms, + gasNotes: rawDive.gasNotes, + gearNotes: rawDive.gearNotes, + issueNotes: rawDive.issueNotes, + ); + + if (userNotes != null && extraNotes != null) { + return '$userNotes\n\n$extraNotes'; + } + return userNotes ?? extraNotes ?? ''; + } + + /// Extracts surface pressure in bar from the first tank's + /// SurfacePressureMBar field. + static double? _extractSurfacePressure(ShearwaterRawDive rawDive) { + final tankData = + rawDive.tankProfileData?['TankData'] as List? ?? []; + if (tankData.isEmpty) return null; + + final firstTank = tankData[0] as Map; + final mbar = _toDouble(firstTank['SurfacePressureMBar']); + if (mbar == null) return null; + return ShearwaterValueMapper.mbarToBar(mbar); + } + + /// Parses a GNSS location string "lat,lon" into a coordinate pair. + static (double, double)? _parseGnssLocation(String? gnss) { + if (gnss == null || gnss.isEmpty) return null; + final parts = gnss.split(','); + if (parts.length != 2) return null; + final lat = double.tryParse(parts[0].trim()); + final lon = double.tryParse(parts[1].trim()); + if (lat == null || lon == null) return null; + return (lat, lon); + } + + static Future _parseWithFfi({ + required String vendor, + required String product, + required Uint8List data, + }) async { + // Look up the libdivecomputer model number. For now, pass 0 as a + // placeholder -- the native side resolves by vendor + product name. + final api = pigeon.DiveComputerHostApi(); + return api.parseRawDiveData(vendor, product, 0, data); + } + + /// Merges parsed profile and deco data into the base metadata map. + @visibleForTesting + static Map mergeWithParsedDive( + Map baseMap, + pigeon.ParsedDive parsed, + ) { + final merged = Map.from(baseMap); + + // Override depth/duration with parsed values (more accurate) + merged['maxDepth'] = parsed.maxDepthMeters; + merged['avgDepth'] = parsed.avgDepthMeters; + merged['runtime'] = Duration(seconds: parsed.durationSeconds); + + // Add deco model info + if (parsed.decoAlgorithm != null) { + merged['decoAlgorithm'] = parsed.decoAlgorithm; + } + if (parsed.gfLow != null) { + merged['gradientFactorLow'] = parsed.gfLow; + } + if (parsed.gfHigh != null) { + merged['gradientFactorHigh'] = parsed.gfHigh; + } + + // Override dive mode from parsed data (more accurate than apparatus guess). + if (parsed.diveMode != null) { + merged['diveMode'] = switch (parsed.diveMode) { + 'ccr' => DiveMode.ccr, + 'scr' => DiveMode.scr, + _ => DiveMode.oc, + }; + } + + // Build profile samples with all available sensor data. + merged['profile'] = parsed.samples.map((s) { + final sampleMap = { + 'timestamp': s.timeSeconds, + 'depth': s.depthMeters, + }; + if (s.temperatureCelsius != null) { + sampleMap['temperature'] = s.temperatureCelsius; + } + if (s.pressureBar != null) { + sampleMap['pressure'] = s.pressureBar; + } + if (s.setpoint != null) { + sampleMap['setpoint'] = s.setpoint; + } + if (s.ppo2 != null) { + sampleMap['ppO2'] = s.ppo2; + } + if (s.heartRate != null) { + sampleMap['heartRate'] = s.heartRate; + } + if (s.cns != null) { + sampleMap['cns'] = s.cns; + } + if (s.rbt != null) { + sampleMap['rbt'] = s.rbt; + } + if (s.tts != null) { + sampleMap['tts'] = s.tts; + } + if (s.decoType != null) { + sampleMap['decoType'] = s.decoType; + } + if (s.decoDepth != null && s.decoType != null && s.decoType != 0) { + sampleMap['ceiling'] = s.decoDepth; + } + if (s.decoType == 0 && s.decoTime != null) { + sampleMap['ndl'] = s.decoTime; + } + return sampleMap; + }).toList(); + + // Extract water temperature from profile samples if not already set + if (merged['waterTemp'] == null) { + final temps = parsed.samples + .map((s) => s.temperatureCelsius) + .whereType() + .toList(); + if (temps.isNotEmpty) { + merged['waterTemp'] = temps.reduce((a, b) => a < b ? a : b); + } + } + + return merged; + } +} diff --git a/lib/features/universal_import/data/services/shearwater_filename_parser.dart b/lib/features/universal_import/data/services/shearwater_filename_parser.dart new file mode 100644 index 000000000..68d7f5355 --- /dev/null +++ b/lib/features/universal_import/data/services/shearwater_filename_parser.dart @@ -0,0 +1,45 @@ +/// Parsed result from a Shearwater Cloud log_data filename. +class ShearwaterFilenameInfo { + final String? model; + final String? serial; + final int? diveNumber; + + const ShearwaterFilenameInfo({this.model, this.serial, this.diveNumber}); +} + +/// Parses Shearwater Cloud log_data filenames to extract model, serial, and dive number. +/// +/// Filename format: "ModelName[HexSerial]#DiveNum YYYY-M-D H-M-S.swlogzp" +class ShearwaterFilenameParser { + static final _pattern = RegExp(r'^(.+?)\[([A-Fa-f0-9]+)\]#(\d+)\s'); + + static const _knownModels = { + 'Teric': ('Shearwater', 'Teric'), + 'Perdix': ('Shearwater', 'Perdix'), + 'Perdix 2': ('Shearwater', 'Perdix 2'), + 'Perdix AI': ('Shearwater', 'Perdix AI'), + 'Peregrine': ('Shearwater', 'Peregrine'), + 'Petrel': ('Shearwater', 'Petrel'), + 'Petrel 2': ('Shearwater', 'Petrel 2'), + 'Petrel 3': ('Shearwater', 'Petrel 3'), + 'Tern': ('Shearwater', 'Tern'), + 'NERD': ('Shearwater', 'NERD'), + 'NERD 2': ('Shearwater', 'NERD 2'), + }; + + static ShearwaterFilenameInfo parse(String filename) { + final match = _pattern.firstMatch(filename); + if (match == null) { + return const ShearwaterFilenameInfo(); + } + return ShearwaterFilenameInfo( + model: match.group(1), + serial: match.group(2), + diveNumber: int.tryParse(match.group(3) ?? ''), + ); + } + + static (String, String)? vendorProduct(String model) { + return _knownModels[model]; + } +} diff --git a/lib/features/universal_import/data/services/shearwater_value_mapper.dart b/lib/features/universal_import/data/services/shearwater_value_mapper.dart new file mode 100644 index 000000000..91a0ad53d --- /dev/null +++ b/lib/features/universal_import/data/services/shearwater_value_mapper.dart @@ -0,0 +1,110 @@ +import 'package:submersion/core/constants/enums.dart'; + +/// Converts Shearwater Cloud field values to Submersion's metric system +/// and maps condition strings to Submersion enums. +class ShearwaterValueMapper { + // --------------------------------------------------------------------------- + // Unit conversions + // --------------------------------------------------------------------------- + + static double psiToBar(num psi) => psi / 14.5038; + + static double fahrenheitToCelsius(num f) => (f - 32) * 5 / 9; + + static double lbsToKg(num lbs) => lbs * 0.453592; + + static double feetToMeters(num feet) => feet * 0.3048; + + static double mbarToBar(num mbar) => mbar / 1000; + + // --------------------------------------------------------------------------- + // Conditions mapping + // --------------------------------------------------------------------------- + + static WaterType? mapWaterType(String? environment) { + if (environment == null || environment.isEmpty) return null; + return switch (environment) { + 'Ocean/Sea' => WaterType.salt, + 'Pool' || 'Lake' || 'Quarry' || 'River' => WaterType.fresh, + 'Brackish' => WaterType.brackish, + _ => null, + }; + } + + static CloudCover? mapCloudCover(String? weather) { + if (weather == null || weather.isEmpty) return null; + return switch (weather) { + 'Sunny' || 'Clear' => CloudCover.clear, + 'Partly Cloudy' => CloudCover.partlyCloudy, + 'Cloudy' || 'Overcast' => CloudCover.mostlyCloudy, + _ => null, + }; + } + + static CurrentStrength? mapCurrentStrength(String? conditions) { + if (conditions == null || conditions.isEmpty) return null; + return switch (conditions) { + 'Current' => CurrentStrength.moderate, + 'Strong Current' => CurrentStrength.strong, + 'Light Current' => CurrentStrength.light, + _ => null, + }; + } + + /// Maps a visibility string value to a [Visibility] enum. + /// + /// [value] is the raw string (e.g. '100'). + /// [isImperial] indicates whether [value] is in feet (true) or meters (false). + static Visibility? mapVisibility(String? value, {bool isImperial = true}) { + if (value == null || value.isEmpty) return null; + final numValue = double.tryParse(value); + if (numValue == null) return null; + final meters = isImperial ? feetToMeters(numValue) : numValue; + if (meters >= 30) return Visibility.excellent; + if (meters >= 15) return Visibility.good; + if (meters >= 5) return Visibility.moderate; + return Visibility.poor; + } + + // --------------------------------------------------------------------------- + // Extra notes builder + // --------------------------------------------------------------------------- + + /// Collects unmapped Shearwater Cloud fields into a structured notes string. + /// + /// Weather and conditions values are only included when they cannot be mapped + /// to a structured enum (i.e. they fall through to null). Returns null when + /// there are no extra fields to record. + static String? buildExtraNotes({ + String? weather, + String? conditions, + String? dress, + String? thermalComfort, + String? workload, + String? problems, + String? malfunctions, + String? symptoms, + String? gasNotes, + String? gearNotes, + String? issueNotes, + }) { + final entries = []; + if (weather != null && mapCloudCover(weather) == null) { + entries.add('Weather: $weather'); + } + if (conditions != null && mapCurrentStrength(conditions) == null) { + entries.add('Conditions: $conditions'); + } + if (dress != null) entries.add('Dress: $dress'); + if (thermalComfort != null) entries.add('Thermal Comfort: $thermalComfort'); + if (workload != null) entries.add('Workload: $workload'); + if (problems != null) entries.add('Problems: $problems'); + if (malfunctions != null) entries.add('Malfunctions: $malfunctions'); + if (symptoms != null) entries.add('Symptoms: $symptoms'); + if (gasNotes != null) entries.add('Gas Notes: $gasNotes'); + if (gearNotes != null) entries.add('Gear Notes: $gearNotes'); + if (issueNotes != null) entries.add('Issue Notes: $issueNotes'); + if (entries.isEmpty) return null; + return '[Shearwater Cloud]\n${entries.join('\n')}'; + } +} diff --git a/lib/features/universal_import/presentation/providers/universal_import_providers.dart b/lib/features/universal_import/presentation/providers/universal_import_providers.dart index 6079f1f16..349a86cb7 100644 --- a/lib/features/universal_import/presentation/providers/universal_import_providers.dart +++ b/lib/features/universal_import/presentation/providers/universal_import_providers.dart @@ -35,8 +35,10 @@ import 'package:submersion/features/universal_import/data/parsers/fit_import_par import 'package:submersion/features/universal_import/data/parsers/import_parser.dart'; import 'package:submersion/features/universal_import/data/parsers/placeholder_parser.dart'; import 'package:submersion/features/universal_import/data/parsers/subsurface_xml_parser.dart'; +import 'package:submersion/features/universal_import/data/parsers/shearwater_cloud_parser.dart'; import 'package:submersion/features/universal_import/data/parsers/uddf_import_parser.dart'; import 'package:submersion/features/universal_import/data/services/format_detector.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_db_reader.dart'; import 'package:submersion/features/universal_import/data/services/import_duplicate_checker.dart'; // ============================================================================ @@ -257,7 +259,20 @@ class UniversalImportNotifier extends StateNotifier { final fileName = pickedFile.name; const detector = FormatDetector(); - final detection = detector.detect(bytes); + var detection = detector.detect(bytes); + + if (detection.format == ImportFormat.sqlite) { + final isShearwater = await ShearwaterDbReader.isShearwaterCloudDb( + bytes, + ); + if (isShearwater) { + detection = const DetectionResult( + format: ImportFormat.shearwaterDb, + sourceApp: SourceApp.shearwater, + confidence: 0.95, + ); + } + } state = state.copyWith( isLoading: false, @@ -400,6 +415,7 @@ class UniversalImportNotifier extends StateNotifier { ImportFormat.uddf => UddfImportParser(), ImportFormat.subsurfaceXml => SubsurfaceXmlParser(), ImportFormat.fit => const FitImportParser(), + ImportFormat.shearwaterDb => ShearwaterCloudParser(), _ => const PlaceholderParser(), }; } diff --git a/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/DiveComputerApi.g.kt b/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/DiveComputerApi.g.kt index e6dc7d81c..e5bbb52f0 100644 --- a/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/DiveComputerApi.g.kt +++ b/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/DiveComputerApi.g.kt @@ -495,6 +495,7 @@ interface DiveComputerHostApi { fun cancelDownload() fun submitPinCode(pinCode: String) fun getLibdivecomputerVersion(): String + fun parseRawDiveData(vendor: String, product: String, model: Long, data: ByteArray, callback: (Result) -> Unit) companion object { /** The codec used by DiveComputerHostApi. */ @@ -627,6 +628,29 @@ interface DiveComputerHostApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.libdivecomputer_plugin.DiveComputerHostApi.parseRawDiveData$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val vendorArg = args[0] as String + val productArg = args[1] as String + val modelArg = args[2] as Long + val dataArg = args[3] as ByteArray + api.parseRawDiveData(vendorArg, productArg, modelArg, dataArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/DiveComputerHostApiImpl.kt b/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/DiveComputerHostApiImpl.kt index 274921a8a..f9d2df309 100644 --- a/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/DiveComputerHostApiImpl.kt +++ b/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/DiveComputerHostApiImpl.kt @@ -395,6 +395,21 @@ class DiveComputerHostApiImpl( ) } + // MARK: - Parse Raw Dive Data + + override fun parseRawDiveData( + vendor: String, + product: String, + model: Long, + data: ByteArray, + callback: (Result) -> Unit + ) { + callback(Result.failure( + FlutterError("UNSUPPORTED", + "Raw dive parsing not yet implemented on Android", + null))) + } + // MARK: - Version override fun getLibdivecomputerVersion(): String { diff --git a/packages/libdivecomputer_plugin/darwin/Sources/LibDCDarwin/DiveComputerHostApiImpl.swift b/packages/libdivecomputer_plugin/darwin/Sources/LibDCDarwin/DiveComputerHostApiImpl.swift index bdc6574d5..7569064a3 100644 --- a/packages/libdivecomputer_plugin/darwin/Sources/LibDCDarwin/DiveComputerHostApiImpl.swift +++ b/packages/libdivecomputer_plugin/darwin/Sources/LibDCDarwin/DiveComputerHostApiImpl.swift @@ -568,6 +568,57 @@ class DiveComputerHostApiImpl: DiveComputerHostApi { } } + // MARK: - Parse Raw Dive Data + + func parseRawDiveData( + vendor: String, + product: String, + model: Int64, + data: FlutterStandardTypedData, + completion: @escaping (Result) -> Void + ) { + DispatchQueue.global(qos: .userInitiated).async { [self] in + var dive = libdc_parsed_dive_t() + var errorBuf = [CChar](repeating: 0, count: 256) + + let result = data.data.withUnsafeBytes { rawPtr -> Int32 in + guard let baseAddress = rawPtr.baseAddress else { return -1 } + return libdc_parse_raw_dive( + vendor, + product, + UInt32(model), + baseAddress.assumingMemoryBound(to: UInt8.self), + UInt32(data.data.count), + &dive, + &errorBuf, + errorBuf.count + ) + } + + if result != 0 { + let errorMsg = String(cString: errorBuf) + free(dive.samples) + free(dive.events) + DispatchQueue.main.async { + completion(.failure(PigeonError( + code: "PARSE_ERROR", + message: "Failed to parse raw dive data: \(errorMsg)", + details: nil + ))) + } + return + } + + let parsedDive = self.convertParsedDive(dive) + free(dive.samples) + free(dive.events) + + DispatchQueue.main.async { + completion(.success(parsedDive)) + } + } + } + // MARK: - Version func getLibdivecomputerVersion() throws -> String { diff --git a/packages/libdivecomputer_plugin/ios/Classes/DiveComputerApi.g.swift b/packages/libdivecomputer_plugin/ios/Classes/DiveComputerApi.g.swift index b90484971..c850e78d7 100644 --- a/packages/libdivecomputer_plugin/ios/Classes/DiveComputerApi.g.swift +++ b/packages/libdivecomputer_plugin/ios/Classes/DiveComputerApi.g.swift @@ -562,6 +562,7 @@ protocol DiveComputerHostApi { func cancelDownload() throws func submitPinCode(pinCode: String) throws func getLibdivecomputerVersion() throws -> String + func parseRawDiveData(vendor: String, product: String, model: Int64, data: FlutterStandardTypedData, completion: @escaping (Result) -> Void) } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -674,6 +675,26 @@ class DiveComputerHostApiSetup { } else { getLibdivecomputerVersionChannel.setMessageHandler(nil) } + let parseRawDiveDataChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.libdivecomputer_plugin.DiveComputerHostApi.parseRawDiveData\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + parseRawDiveDataChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let vendorArg = args[0] as! String + let productArg = args[1] as! String + let modelArg = args[2] as! Int64 + let dataArg = args[3] as! FlutterStandardTypedData + api.parseRawDiveData(vendor: vendorArg, product: productArg, model: modelArg, data: dataArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + parseRawDiveDataChannel.setMessageHandler(nil) + } } } /// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. diff --git a/packages/libdivecomputer_plugin/lib/src/generated/dive_computer_api.g.dart b/packages/libdivecomputer_plugin/lib/src/generated/dive_computer_api.g.dart index 39ca81de9..89c4130fd 100644 --- a/packages/libdivecomputer_plugin/lib/src/generated/dive_computer_api.g.dart +++ b/packages/libdivecomputer_plugin/lib/src/generated/dive_computer_api.g.dart @@ -722,6 +722,41 @@ class DiveComputerHostApi { return (pigeonVar_replyList[0] as String?)!; } } + + Future parseRawDiveData( + String vendor, + String product, + int model, + Uint8List data, + ) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.libdivecomputer_plugin.DiveComputerHostApi.parseRawDiveData$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([vendor, product, model, data]) + as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as ParsedDive?)!; + } + } } abstract class DiveComputerFlutterApi { diff --git a/packages/libdivecomputer_plugin/linux/dive_computer_api.g.cc b/packages/libdivecomputer_plugin/linux/dive_computer_api.g.cc index 3b50ac536..f045ed647 100644 --- a/packages/libdivecomputer_plugin/linux/dive_computer_api.g.cc +++ b/packages/libdivecomputer_plugin/linux/dive_computer_api.g.cc @@ -1801,6 +1801,45 @@ LibdivecomputerPluginDiveComputerHostApiGetLibdivecomputerVersionResponse* libdi return self; } +G_DECLARE_FINAL_TYPE(LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse, libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response, LIBDIVECOMPUTER_PLUGIN, DIVE_COMPUTER_HOST_API_PARSE_RAW_DIVE_DATA_RESPONSE, GObject) + +struct _LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse { + GObject parent_instance; + + FlValue* value; +}; + +G_DEFINE_TYPE(LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse, libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response, G_TYPE_OBJECT) + +static void libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_dispose(GObject* object) { + LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse* self = LIBDIVECOMPUTER_PLUGIN_DIVE_COMPUTER_HOST_API_PARSE_RAW_DIVE_DATA_RESPONSE(object); + g_clear_pointer(&self->value, fl_value_unref); + G_OBJECT_CLASS(libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_parent_class)->dispose(object); +} + +static void libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_init(LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse* self) { +} + +static void libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_class_init(LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponseClass* klass) { + G_OBJECT_CLASS(klass)->dispose = libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_dispose; +} + +static LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse* libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_new(LibdivecomputerPluginParsedDive* return_value) { + LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse* self = LIBDIVECOMPUTER_PLUGIN_DIVE_COMPUTER_HOST_API_PARSE_RAW_DIVE_DATA_RESPONSE(g_object_new(libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_get_type(), nullptr)); + self->value = fl_value_new_list(); + fl_value_append_take(self->value, fl_value_new_custom_object(136, G_OBJECT(return_value))); + return self; +} + +static LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse* libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_new_error(const gchar* code, const gchar* message, FlValue* details) { + LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse* self = LIBDIVECOMPUTER_PLUGIN_DIVE_COMPUTER_HOST_API_PARSE_RAW_DIVE_DATA_RESPONSE(g_object_new(libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_get_type(), nullptr)); + self->value = fl_value_new_list(); + fl_value_append_take(self->value, fl_value_new_string(code)); + fl_value_append_take(self->value, fl_value_new_string(message != nullptr ? message : "")); + fl_value_append_take(self->value, details != nullptr ? fl_value_ref(details) : fl_value_new_null()); + return self; +} + struct _LibdivecomputerPluginDiveComputerHostApi { GObject parent_instance; @@ -1952,6 +1991,26 @@ static void libdivecomputer_plugin_dive_computer_host_api_get_libdivecomputer_ve } } +static void libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_cb(FlBasicMessageChannel* channel, FlValue* message_, FlBasicMessageChannelResponseHandle* response_handle, gpointer user_data) { + LibdivecomputerPluginDiveComputerHostApi* self = LIBDIVECOMPUTER_PLUGIN_DIVE_COMPUTER_HOST_API(user_data); + + if (self->vtable == nullptr || self->vtable->parse_raw_dive_data == nullptr) { + return; + } + + FlValue* value0 = fl_value_get_list_value(message_, 0); + const gchar* vendor = fl_value_get_string(value0); + FlValue* value1 = fl_value_get_list_value(message_, 1); + const gchar* product = fl_value_get_string(value1); + FlValue* value2 = fl_value_get_list_value(message_, 2); + int64_t model = fl_value_get_int(value2); + FlValue* value3 = fl_value_get_list_value(message_, 3); + const uint8_t* data = fl_value_get_uint8_list(value3); + size_t data_length = fl_value_get_length(value3); + g_autoptr(LibdivecomputerPluginDiveComputerHostApiResponseHandle) handle = libdivecomputer_plugin_dive_computer_host_api_response_handle_new(channel, response_handle); + self->vtable->parse_raw_dive_data(vendor, product, model, data, data_length, handle, self->user_data); +} + void libdivecomputer_plugin_dive_computer_host_api_set_method_handlers(FlBinaryMessenger* messenger, const gchar* suffix, const LibdivecomputerPluginDiveComputerHostApiVTable* vtable, gpointer user_data, GDestroyNotify user_data_free_func) { g_autofree gchar* dot_suffix = suffix != nullptr ? g_strdup_printf(".%s", suffix) : g_strdup(""); g_autoptr(LibdivecomputerPluginDiveComputerHostApi) api_data = libdivecomputer_plugin_dive_computer_host_api_new(vtable, user_data, user_data_free_func); @@ -1978,6 +2037,9 @@ void libdivecomputer_plugin_dive_computer_host_api_set_method_handlers(FlBinaryM g_autofree gchar* get_libdivecomputer_version_channel_name = g_strdup_printf("dev.flutter.pigeon.libdivecomputer_plugin.DiveComputerHostApi.getLibdivecomputerVersion%s", dot_suffix); g_autoptr(FlBasicMessageChannel) get_libdivecomputer_version_channel = fl_basic_message_channel_new(messenger, get_libdivecomputer_version_channel_name, FL_MESSAGE_CODEC(codec)); fl_basic_message_channel_set_message_handler(get_libdivecomputer_version_channel, libdivecomputer_plugin_dive_computer_host_api_get_libdivecomputer_version_cb, g_object_ref(api_data), g_object_unref); + g_autofree gchar* parse_raw_dive_data_channel_name = g_strdup_printf("dev.flutter.pigeon.libdivecomputer_plugin.DiveComputerHostApi.parseRawDiveData%s", dot_suffix); + g_autoptr(FlBasicMessageChannel) parse_raw_dive_data_channel = fl_basic_message_channel_new(messenger, parse_raw_dive_data_channel_name, FL_MESSAGE_CODEC(codec)); + fl_basic_message_channel_set_message_handler(parse_raw_dive_data_channel, libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_cb, g_object_ref(api_data), g_object_unref); } void libdivecomputer_plugin_dive_computer_host_api_clear_method_handlers(FlBinaryMessenger* messenger, const gchar* suffix) { @@ -2005,6 +2067,9 @@ void libdivecomputer_plugin_dive_computer_host_api_clear_method_handlers(FlBinar g_autofree gchar* get_libdivecomputer_version_channel_name = g_strdup_printf("dev.flutter.pigeon.libdivecomputer_plugin.DiveComputerHostApi.getLibdivecomputerVersion%s", dot_suffix); g_autoptr(FlBasicMessageChannel) get_libdivecomputer_version_channel = fl_basic_message_channel_new(messenger, get_libdivecomputer_version_channel_name, FL_MESSAGE_CODEC(codec)); fl_basic_message_channel_set_message_handler(get_libdivecomputer_version_channel, nullptr, nullptr, nullptr); + g_autofree gchar* parse_raw_dive_data_channel_name = g_strdup_printf("dev.flutter.pigeon.libdivecomputer_plugin.DiveComputerHostApi.parseRawDiveData%s", dot_suffix); + g_autoptr(FlBasicMessageChannel) parse_raw_dive_data_channel = fl_basic_message_channel_new(messenger, parse_raw_dive_data_channel_name, FL_MESSAGE_CODEC(codec)); + fl_basic_message_channel_set_message_handler(parse_raw_dive_data_channel, nullptr, nullptr, nullptr); } void libdivecomputer_plugin_dive_computer_host_api_respond_get_device_descriptors(LibdivecomputerPluginDiveComputerHostApiResponseHandle* response_handle, FlValue* return_value) { @@ -2055,6 +2120,22 @@ void libdivecomputer_plugin_dive_computer_host_api_respond_error_start_download( } } +void libdivecomputer_plugin_dive_computer_host_api_respond_parse_raw_dive_data(LibdivecomputerPluginDiveComputerHostApiResponseHandle* response_handle, LibdivecomputerPluginParsedDive* return_value) { + g_autoptr(LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse) response = libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_new(return_value); + g_autoptr(GError) error = nullptr; + if (!fl_basic_message_channel_respond(response_handle->channel, response_handle->response_handle, response->value, &error)) { + g_warning("Failed to send response to %s.%s: %s", "DiveComputerHostApi", "parseRawDiveData", error->message); + } +} + +void libdivecomputer_plugin_dive_computer_host_api_respond_error_parse_raw_dive_data(LibdivecomputerPluginDiveComputerHostApiResponseHandle* response_handle, const gchar* code, const gchar* message, FlValue* details) { + g_autoptr(LibdivecomputerPluginDiveComputerHostApiParseRawDiveDataResponse) response = libdivecomputer_plugin_dive_computer_host_api_parse_raw_dive_data_response_new_error(code, message, details); + g_autoptr(GError) error = nullptr; + if (!fl_basic_message_channel_respond(response_handle->channel, response_handle->response_handle, response->value, &error)) { + g_warning("Failed to send response to %s.%s: %s", "DiveComputerHostApi", "parseRawDiveData", error->message); + } +} + struct _LibdivecomputerPluginDiveComputerFlutterApi { GObject parent_instance; diff --git a/packages/libdivecomputer_plugin/linux/dive_computer_api.g.h b/packages/libdivecomputer_plugin/linux/dive_computer_api.g.h index d0917c3ad..e67469745 100644 --- a/packages/libdivecomputer_plugin/linux/dive_computer_api.g.h +++ b/packages/libdivecomputer_plugin/linux/dive_computer_api.g.h @@ -960,6 +960,7 @@ typedef struct { LibdivecomputerPluginDiveComputerHostApiCancelDownloadResponse* (*cancel_download)(gpointer user_data); LibdivecomputerPluginDiveComputerHostApiSubmitPinCodeResponse* (*submit_pin_code)(const gchar* pin_code, gpointer user_data); LibdivecomputerPluginDiveComputerHostApiGetLibdivecomputerVersionResponse* (*get_libdivecomputer_version)(gpointer user_data); + void (*parse_raw_dive_data)(const gchar* vendor, const gchar* product, int64_t model, const uint8_t* data, size_t data_length, LibdivecomputerPluginDiveComputerHostApiResponseHandle* response_handle, gpointer user_data); } LibdivecomputerPluginDiveComputerHostApiVTable; /** @@ -1043,6 +1044,26 @@ void libdivecomputer_plugin_dive_computer_host_api_respond_start_download(Libdiv */ void libdivecomputer_plugin_dive_computer_host_api_respond_error_start_download(LibdivecomputerPluginDiveComputerHostApiResponseHandle* response_handle, const gchar* code, const gchar* message, FlValue* details); +/** + * libdivecomputer_plugin_dive_computer_host_api_respond_parse_raw_dive_data: + * @response_handle: a #LibdivecomputerPluginDiveComputerHostApiResponseHandle. + * @return_value: location to write the value returned by this method. + * + * Responds to DiveComputerHostApi.parseRawDiveData. + */ +void libdivecomputer_plugin_dive_computer_host_api_respond_parse_raw_dive_data(LibdivecomputerPluginDiveComputerHostApiResponseHandle* response_handle, LibdivecomputerPluginParsedDive* return_value); + +/** + * libdivecomputer_plugin_dive_computer_host_api_respond_error_parse_raw_dive_data: + * @response_handle: a #LibdivecomputerPluginDiveComputerHostApiResponseHandle. + * @code: error code. + * @message: error message. + * @details: (allow-none): error details or %NULL. + * + * Responds with an error to DiveComputerHostApi.parseRawDiveData. + */ +void libdivecomputer_plugin_dive_computer_host_api_respond_error_parse_raw_dive_data(LibdivecomputerPluginDiveComputerHostApiResponseHandle* response_handle, const gchar* code, const gchar* message, FlValue* details); + G_DECLARE_FINAL_TYPE(LibdivecomputerPluginDiveComputerFlutterApiOnDeviceDiscoveredResponse, libdivecomputer_plugin_dive_computer_flutter_api_on_device_discovered_response, LIBDIVECOMPUTER_PLUGIN, DIVE_COMPUTER_FLUTTER_API_ON_DEVICE_DISCOVERED_RESPONSE, GObject) /** diff --git a/packages/libdivecomputer_plugin/linux/dive_computer_host_api_impl.cc b/packages/libdivecomputer_plugin/linux/dive_computer_host_api_impl.cc index 0e227d506..8d8fa8c97 100644 --- a/packages/libdivecomputer_plugin/linux/dive_computer_host_api_impl.cc +++ b/packages/libdivecomputer_plugin/linux/dive_computer_host_api_impl.cc @@ -690,6 +690,19 @@ handle_get_libdivecomputer_version(gpointer user_data) { version); } +static void handle_parse_raw_dive_data( + const gchar* vendor, + const gchar* product, + int64_t model, + const uint8_t* data, + size_t data_length, + LibdivecomputerPluginDiveComputerHostApiResponseHandle* response_handle, + gpointer user_data) { + libdivecomputer_plugin_dive_computer_host_api_respond_error_parse_raw_dive_data( + response_handle, "UNSUPPORTED", + "Raw dive parsing not yet implemented on Linux", nullptr); +} + // --- Public registration --- void dive_computer_host_api_impl_register(FlBinaryMessenger* messenger) { @@ -712,6 +725,7 @@ void dive_computer_host_api_impl_register(FlBinaryMessenger* messenger) { .cancel_download = handle_cancel_download, .submit_pin_code = handle_submit_pin_code, .get_libdivecomputer_version = handle_get_libdivecomputer_version, + .parse_raw_dive_data = handle_parse_raw_dive_data, }; libdivecomputer_plugin_dive_computer_host_api_set_method_handlers( diff --git a/packages/libdivecomputer_plugin/macos/Classes/DiveComputerApi.g.swift b/packages/libdivecomputer_plugin/macos/Classes/DiveComputerApi.g.swift index b90484971..c850e78d7 100644 --- a/packages/libdivecomputer_plugin/macos/Classes/DiveComputerApi.g.swift +++ b/packages/libdivecomputer_plugin/macos/Classes/DiveComputerApi.g.swift @@ -562,6 +562,7 @@ protocol DiveComputerHostApi { func cancelDownload() throws func submitPinCode(pinCode: String) throws func getLibdivecomputerVersion() throws -> String + func parseRawDiveData(vendor: String, product: String, model: Int64, data: FlutterStandardTypedData, completion: @escaping (Result) -> Void) } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -674,6 +675,26 @@ class DiveComputerHostApiSetup { } else { getLibdivecomputerVersionChannel.setMessageHandler(nil) } + let parseRawDiveDataChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.libdivecomputer_plugin.DiveComputerHostApi.parseRawDiveData\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + parseRawDiveDataChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let vendorArg = args[0] as! String + let productArg = args[1] as! String + let modelArg = args[2] as! Int64 + let dataArg = args[3] as! FlutterStandardTypedData + api.parseRawDiveData(vendor: vendorArg, product: productArg, model: modelArg, data: dataArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + parseRawDiveDataChannel.setMessageHandler(nil) + } } } /// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. diff --git a/packages/libdivecomputer_plugin/macos/Classes/libdc_download.c b/packages/libdivecomputer_plugin/macos/Classes/libdc_download.c index 22daa10bb..5dd37ee15 100644 --- a/packages/libdivecomputer_plugin/macos/Classes/libdc_download.c +++ b/packages/libdivecomputer_plugin/macos/Classes/libdc_download.c @@ -4,6 +4,7 @@ #include "libdc_wrapper.h" #include #include +#include #include #include @@ -75,7 +76,8 @@ static dc_descriptor_t *find_descriptor(const char *vendor, const char *product, const char *p = dc_descriptor_get_product(desc); unsigned int m = dc_descriptor_get_model(desc); if (v != NULL && p != NULL && - strcmp(v, vendor) == 0 && strcmp(p, product) == 0 && m == model) { + strcmp(v, vendor) == 0 && strcmp(p, product) == 0 && + (model == 0 || m == model)) { match = desc; break; } @@ -229,36 +231,10 @@ static void sample_callback(dc_sample_type_t type, } } -static int parse_dive(download_state_t *state, - const unsigned char *data, unsigned int size, - const unsigned char *fingerprint, unsigned int fsize, - libdc_parsed_dive_t *dive) { - memset(dive, 0, sizeof(*dive)); - dive->min_temp = NAN; - dive->max_temp = NAN; - dive->deco_model_type = 0; // DC_DECOMODEL_NONE - dive->deco_conservatism = 0; - dive->gf_low = 0; - dive->gf_high = 0; - dive->events = NULL; - dive->event_count = 0; - dive->event_capacity = 0; - - // Store fingerprint. - if (fingerprint != NULL && fsize > 0) { - unsigned int copy_size = fsize < LIBDC_MAX_FINGERPRINT ? - fsize : LIBDC_MAX_FINGERPRINT; - memcpy(dive->fingerprint, fingerprint, copy_size); - dive->fingerprint_size = copy_size; - } - - // Create parser. - dc_parser_t *parser = NULL; - dc_status_t status = dc_parser_new(&parser, state->device, data, size); - if (status != DC_STATUS_SUCCESS || parser == NULL) { - return -1; - } - +// Extract all fields (datetime, summary, deco model, gas mixes, tanks, samples) +// from an already-created parser into the dive struct. +// Returns 0 on success. +static int extract_dive_fields(dc_parser_t *parser, libdc_parsed_dive_t *dive) { // Extract datetime. dc_datetime_t dt = {0}; if (dc_parser_get_datetime(parser, &dt) == DC_STATUS_SUCCESS) { @@ -297,7 +273,7 @@ static int parse_dive(download_state_t *state, // Extract decompression model. dc_decomodel_t decomodel = {0}; if (dc_parser_get_field(parser, DC_FIELD_DECOMODEL, 0, &decomodel) == DC_STATUS_SUCCESS) { - dive->deco_model_type = decomodel.type; // DC_DECOMODEL_NONE=0, BUHLMANN=1, VPM=2, RGBM=3, DCIEM=4 + dive->deco_model_type = decomodel.type; dive->deco_conservatism = decomodel.conservatism; dive->gf_low = decomodel.params.gf.low; dive->gf_high = decomodel.params.gf.high; @@ -306,9 +282,7 @@ static int parse_dive(download_state_t *state, // Extract gas mixes. unsigned int gasmix_count = 0; if (dc_parser_get_field(parser, DC_FIELD_GASMIX_COUNT, 0, &gasmix_count) == DC_STATUS_SUCCESS) { - if (gasmix_count > LIBDC_MAX_GASMIXES) { - gasmix_count = LIBDC_MAX_GASMIXES; - } + if (gasmix_count > LIBDC_MAX_GASMIXES) gasmix_count = LIBDC_MAX_GASMIXES; for (unsigned int i = 0; i < gasmix_count; i++) { dc_gasmix_t gm = {0}; if (dc_parser_get_field(parser, DC_FIELD_GASMIX, i, &gm) == DC_STATUS_SUCCESS) { @@ -322,9 +296,7 @@ static int parse_dive(download_state_t *state, // Extract tanks. unsigned int tank_count = 0; if (dc_parser_get_field(parser, DC_FIELD_TANK_COUNT, 0, &tank_count) == DC_STATUS_SUCCESS) { - if (tank_count > LIBDC_MAX_TANKS) { - tank_count = LIBDC_MAX_TANKS; - } + if (tank_count > LIBDC_MAX_TANKS) tank_count = LIBDC_MAX_TANKS; for (unsigned int i = 0; i < tank_count; i++) { dc_tank_t tk = {0}; if (dc_parser_get_field(parser, DC_FIELD_TANK, i, &tk) == DC_STATUS_SUCCESS) { @@ -344,10 +316,45 @@ static int parse_dive(download_state_t *state, dc_parser_samples_foreach(parser, sample_callback, &sample_state); push_sample(&sample_state); - dc_parser_destroy(parser); return 0; } +static int parse_dive(download_state_t *state, + const unsigned char *data, unsigned int size, + const unsigned char *fingerprint, unsigned int fsize, + libdc_parsed_dive_t *dive) { + memset(dive, 0, sizeof(*dive)); + dive->min_temp = NAN; + dive->max_temp = NAN; + dive->deco_model_type = 0; // DC_DECOMODEL_NONE + dive->deco_conservatism = 0; + dive->gf_low = 0; + dive->gf_high = 0; + dive->events = NULL; + dive->event_count = 0; + dive->event_capacity = 0; + + // Store fingerprint. + if (fingerprint != NULL && fsize > 0) { + unsigned int copy_size = fsize < LIBDC_MAX_FINGERPRINT ? + fsize : LIBDC_MAX_FINGERPRINT; + memcpy(dive->fingerprint, fingerprint, copy_size); + dive->fingerprint_size = copy_size; + } + + // Create parser. + dc_parser_t *parser = NULL; + dc_status_t status = dc_parser_new(&parser, state->device, data, size); + if (status != DC_STATUS_SUCCESS || parser == NULL) { + return -1; + } + + int result = extract_dive_fields(parser, dive); + + dc_parser_destroy(parser); + return result; +} + static int dive_callback(const unsigned char *data, unsigned int size, const unsigned char *fingerprint, unsigned int fsize, void *userdata) { @@ -614,3 +621,74 @@ int libdc_download_run( return result; } + +// ============================================================ +// Standalone Raw Dive Parsing +// ============================================================ + +int libdc_parse_raw_dive( + const char *vendor, const char *product, unsigned int model, + const unsigned char *data, unsigned int size, + libdc_parsed_dive_t *result, + char *error_buf, size_t error_buf_size) +{ + if (vendor == NULL || product == NULL || data == NULL || + size == 0 || result == NULL) { + if (error_buf && error_buf_size > 0) { + strncpy(error_buf, "Invalid arguments", error_buf_size - 1); + error_buf[error_buf_size - 1] = '\0'; + } + return LIBDC_STATUS_INVALIDARGS; + } + + memset(result, 0, sizeof(*result)); + result->min_temp = NAN; + result->max_temp = NAN; + result->events = NULL; + result->event_count = 0; + result->event_capacity = 0; + + dc_context_t *context = NULL; + dc_status_t status = dc_context_new(&context); + if (status != DC_STATUS_SUCCESS) { + if (error_buf && error_buf_size > 0) { + strncpy(error_buf, "Failed to create context", error_buf_size - 1); + error_buf[error_buf_size - 1] = '\0'; + } + return (int)status; + } + + dc_descriptor_t *descriptor = find_descriptor(vendor, product, model); + if (descriptor == NULL) { + dc_context_free(context); + if (error_buf && error_buf_size > 0) + snprintf(error_buf, error_buf_size, + "No descriptor for %s %s (model %u)", + vendor, product, model); + return LIBDC_STATUS_NODEVICE; + } + + dc_parser_t *parser = NULL; + status = dc_parser_new2(&parser, context, descriptor, data, size); + if (status != DC_STATUS_SUCCESS || parser == NULL) { + dc_descriptor_free(descriptor); + dc_context_free(context); + if (error_buf && error_buf_size > 0) + snprintf(error_buf, error_buf_size, + "Parser creation failed (status %d)", (int)status); + return (int)status; + } + + int parse_result = extract_dive_fields(parser, result); + + dc_parser_destroy(parser); + dc_descriptor_free(descriptor); + dc_context_free(context); + + if (parse_result != 0 && error_buf && error_buf_size > 0) { + strncpy(error_buf, "Field extraction failed", error_buf_size - 1); + error_buf[error_buf_size - 1] = '\0'; + } + + return parse_result; +} diff --git a/packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.h b/packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.h index f1a54e4b7..6367302b7 100644 --- a/packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.h +++ b/packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.h @@ -246,4 +246,18 @@ void libdc_download_cancel(libdc_download_session_t *session); // Free the session. void libdc_download_session_free(libdc_download_session_t *session); +// ============================================================ +// Standalone Raw Dive Parsing +// ============================================================ + +/// Parse raw dive computer binary data without a device connection. +/// Uses dc_parser_new2() with a descriptor looked up by vendor/product/model. +/// Returns 0 on success, negative on failure. +/// The caller must free result->samples and result->events when done. +int libdc_parse_raw_dive( + const char *vendor, const char *product, unsigned int model, + const unsigned char *data, unsigned int size, + libdc_parsed_dive_t *result, + char *error_buf, size_t error_buf_size); + #endif diff --git a/packages/libdivecomputer_plugin/pigeons/dive_computer_api.dart b/packages/libdivecomputer_plugin/pigeons/dive_computer_api.dart index 1cf30d40e..b4b1f4f73 100644 --- a/packages/libdivecomputer_plugin/pigeons/dive_computer_api.dart +++ b/packages/libdivecomputer_plugin/pigeons/dive_computer_api.dart @@ -202,6 +202,14 @@ abstract class DiveComputerHostApi { void submitPinCode(String pinCode); String getLibdivecomputerVersion(); + + @async + ParsedDive parseRawDiveData( + String vendor, + String product, + int model, + Uint8List data, + ); } // === Flutter API (Native -> Dart) === diff --git a/packages/libdivecomputer_plugin/windows/dive_computer_api.g.cc b/packages/libdivecomputer_plugin/windows/dive_computer_api.g.cc index d879a6ff3..cdf1dbaeb 100644 --- a/packages/libdivecomputer_plugin/windows/dive_computer_api.g.cc +++ b/packages/libdivecomputer_plugin/windows/dive_computer_api.g.cc @@ -1496,6 +1496,53 @@ void DiveComputerHostApi::SetUp( channel.SetMessageHandler(nullptr); } } + { + BasicMessageChannel<> channel(binary_messenger, "dev.flutter.pigeon.libdivecomputer_plugin.DiveComputerHostApi.parseRawDiveData" + prepended_suffix, &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler([api](const EncodableValue& message, const flutter::MessageReply& reply) { + try { + const auto& args = std::get(message); + const auto& encodable_vendor_arg = args.at(0); + if (encodable_vendor_arg.IsNull()) { + reply(WrapError("vendor_arg unexpectedly null.")); + return; + } + const auto& vendor_arg = std::get(encodable_vendor_arg); + const auto& encodable_product_arg = args.at(1); + if (encodable_product_arg.IsNull()) { + reply(WrapError("product_arg unexpectedly null.")); + return; + } + const auto& product_arg = std::get(encodable_product_arg); + const auto& encodable_model_arg = args.at(2); + if (encodable_model_arg.IsNull()) { + reply(WrapError("model_arg unexpectedly null.")); + return; + } + const int64_t model_arg = encodable_model_arg.LongValue(); + const auto& encodable_data_arg = args.at(3); + if (encodable_data_arg.IsNull()) { + reply(WrapError("data_arg unexpectedly null.")); + return; + } + const auto& data_arg = std::get>(encodable_data_arg); + api->ParseRawDiveData(vendor_arg, product_arg, model_arg, data_arg, [reply](ErrorOr&& output) { + if (output.has_error()) { + reply(WrapError(output.error())); + return; + } + EncodableList wrapped; + wrapped.push_back(CustomEncodableValue(std::move(output).TakeValue())); + reply(EncodableValue(std::move(wrapped))); + }); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } } EncodableValue DiveComputerHostApi::WrapError(std::string_view error_message) { diff --git a/packages/libdivecomputer_plugin/windows/dive_computer_api.g.h b/packages/libdivecomputer_plugin/windows/dive_computer_api.g.h index 8d6aadd53..f565bd3d3 100644 --- a/packages/libdivecomputer_plugin/windows/dive_computer_api.g.h +++ b/packages/libdivecomputer_plugin/windows/dive_computer_api.g.h @@ -630,6 +630,12 @@ class DiveComputerHostApi { virtual std::optional CancelDownload() = 0; virtual std::optional SubmitPinCode(const std::string& pin_code) = 0; virtual ErrorOr GetLibdivecomputerVersion() = 0; + virtual void ParseRawDiveData( + const std::string& vendor, + const std::string& product, + int64_t model, + const std::vector& data, + std::function reply)> result) = 0; // The codec used by DiveComputerHostApi. static const flutter::StandardMessageCodec& GetCodec(); diff --git a/packages/libdivecomputer_plugin/windows/dive_computer_host_api_impl.cc b/packages/libdivecomputer_plugin/windows/dive_computer_host_api_impl.cc index 7ccc1613e..d1393c510 100644 --- a/packages/libdivecomputer_plugin/windows/dive_computer_host_api_impl.cc +++ b/packages/libdivecomputer_plugin/windows/dive_computer_host_api_impl.cc @@ -154,6 +154,16 @@ std::optional DiveComputerHostApiImpl::SubmitPinCode( return std::nullopt; } +void DiveComputerHostApiImpl::ParseRawDiveData( + const std::string& vendor, + const std::string& product, + int64_t model, + const std::vector& data, + std::function reply)> result) { + result(FlutterError("UNSUPPORTED", + "Raw dive parsing not yet implemented on Windows")); +} + ErrorOr DiveComputerHostApiImpl::GetLibdivecomputerVersion() { const char* version = libdc_get_version(); return std::string(version); diff --git a/packages/libdivecomputer_plugin/windows/dive_computer_host_api_impl.h b/packages/libdivecomputer_plugin/windows/dive_computer_host_api_impl.h index 610915aa9..ffed194bc 100644 --- a/packages/libdivecomputer_plugin/windows/dive_computer_host_api_impl.h +++ b/packages/libdivecomputer_plugin/windows/dive_computer_host_api_impl.h @@ -46,6 +46,13 @@ class DiveComputerHostApiImpl : public DiveComputerHostApi, std::optional SubmitPinCode(const std::string& pin_code) override; + void ParseRawDiveData( + const std::string& vendor, + const std::string& product, + int64_t model, + const std::vector& data, + std::function reply)> result) override; + ErrorOr GetLibdivecomputerVersion() override; private: diff --git a/test/features/universal_import/data/models/detection_result_test.dart b/test/features/universal_import/data/models/detection_result_test.dart index 803a437c2..47796fc7b 100644 --- a/test/features/universal_import/data/models/detection_result_test.dart +++ b/test/features/universal_import/data/models/detection_result_test.dart @@ -77,7 +77,7 @@ void main() { test('returns false for unsupported formats', () { const result = DetectionResult( - format: ImportFormat.shearwaterDb, + format: ImportFormat.divingLogXml, confidence: 0.9, ); diff --git a/test/features/universal_import/data/models/import_enums_test.dart b/test/features/universal_import/data/models/import_enums_test.dart index e3135a0f7..a679a07a7 100644 --- a/test/features/universal_import/data/models/import_enums_test.dart +++ b/test/features/universal_import/data/models/import_enums_test.dart @@ -43,18 +43,21 @@ void main() { expect(ImportFormat.unknown.displayName, 'Unknown'); }); - test('isSupported returns true for csv, uddf, subsurfaceXml, fit', () { - expect(ImportFormat.csv.isSupported, isTrue); - expect(ImportFormat.uddf.isSupported, isTrue); - expect(ImportFormat.subsurfaceXml.isSupported, isTrue); - expect(ImportFormat.fit.isSupported, isTrue); - }); + test( + 'isSupported returns true for csv, uddf, subsurfaceXml, fit, shearwaterDb', + () { + expect(ImportFormat.csv.isSupported, isTrue); + expect(ImportFormat.uddf.isSupported, isTrue); + expect(ImportFormat.subsurfaceXml.isSupported, isTrue); + expect(ImportFormat.fit.isSupported, isTrue); + expect(ImportFormat.shearwaterDb.isSupported, isTrue); + }, + ); test('isSupported returns false for unsupported formats', () { expect(ImportFormat.divingLogXml.isSupported, isFalse); expect(ImportFormat.suuntoSml.isSupported, isFalse); expect(ImportFormat.suuntoDm5.isSupported, isFalse); - expect(ImportFormat.shearwaterDb.isSupported, isFalse); expect(ImportFormat.scubapro.isSupported, isFalse); expect(ImportFormat.danDl7.isSupported, isFalse); expect(ImportFormat.sqlite.isSupported, isFalse); @@ -83,7 +86,6 @@ void main() { }); test('exportInstructions is non-null for known apps', () { - expect(SourceApp.shearwater.exportInstructions, isNotNull); expect(SourceApp.suunto.exportInstructions, isNotNull); expect(SourceApp.scubapro.exportInstructions, isNotNull); expect(SourceApp.ssiMyDiveGuide.exportInstructions, isNotNull); @@ -91,6 +93,8 @@ void main() { }); test('exportInstructions is null for apps without instructions', () { + // shearwater now uses native .db import, so no export instructions needed + expect(SourceApp.shearwater.exportInstructions, isNull); expect(SourceApp.submersion.exportInstructions, isNull); expect(SourceApp.subsurface.exportInstructions, isNull); expect(SourceApp.macdive.exportInstructions, isNull); diff --git a/test/features/universal_import/data/parsers/placeholder_parser_test.dart b/test/features/universal_import/data/parsers/placeholder_parser_test.dart index 668989676..2741f097d 100644 --- a/test/features/universal_import/data/parsers/placeholder_parser_test.dart +++ b/test/features/universal_import/data/parsers/placeholder_parser_test.dart @@ -14,11 +14,14 @@ void main() { expect(parser.supportedFormats, contains(ImportFormat.divingLogXml)); expect(parser.supportedFormats, contains(ImportFormat.suuntoSml)); expect(parser.supportedFormats, contains(ImportFormat.suuntoDm5)); - expect(parser.supportedFormats, contains(ImportFormat.shearwaterDb)); expect(parser.supportedFormats, contains(ImportFormat.scubapro)); expect(parser.supportedFormats, contains(ImportFormat.danDl7)); expect(parser.supportedFormats, contains(ImportFormat.sqlite)); expect(parser.supportedFormats, contains(ImportFormat.unknown)); + expect( + parser.supportedFormats, + isNot(contains(ImportFormat.shearwaterDb)), + ); }); }); diff --git a/test/features/universal_import/data/parsers/shearwater_cloud_parser_test.dart b/test/features/universal_import/data/parsers/shearwater_cloud_parser_test.dart new file mode 100644 index 000000000..f533d0f0a --- /dev/null +++ b/test/features/universal_import/data/parsers/shearwater_cloud_parser_test.dart @@ -0,0 +1,220 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_options.dart'; +import 'package:submersion/features/universal_import/data/models/import_warning.dart'; +import 'package:submersion/features/universal_import/data/parsers/shearwater_cloud_parser.dart'; + +import '../services/shearwater_test_helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('ShearwaterCloudParser', () { + test('supportedFormats includes shearwaterDb', () { + final parser = ShearwaterCloudParser(); + expect(parser.supportedFormats, contains(ImportFormat.shearwaterDb)); + }); + + test('parse returns error for non-Shearwater bytes', () async { + final parser = ShearwaterCloudParser(); + final payload = await parser.parse(Uint8List.fromList([1, 2, 3])); + expect(payload.isEmpty, isTrue); + expect(payload.warnings, isNotEmpty); + expect(payload.warnings.first.severity, ImportWarningSeverity.error); + }); + + test('parse returns info warning for empty database', () async { + final parser = ShearwaterCloudParser(); + final bytes = createShearwaterTestDb(); + final payload = await parser.parse(bytes); + expect(payload.isEmpty, isTrue); + expect(payload.warnings, hasLength(1)); + expect(payload.warnings.first.severity, ImportWarningSeverity.info); + expect(payload.warnings.first.message, contains('no dives')); + }); + + test('parse returns dives from synthetic database', () async { + final tankJson = jsonEncode({ + 'TankData': [ + { + 'StartPressurePSI': '3000', + 'EndPressurePSI': '1500', + 'GasProfile': {'O2Percent': 32, 'HePercent': 0}, + 'DiveTransmitter': {'IsOn': true, 'Name': 'T1'}, + 'SurfacePressureMBar': 1013.0, + }, + ], + }); + final footerBlob = jsonToBlob({ + 'UnitSystem': 0, + 'DiveTimeInSeconds': 3600, + }); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive( + diveId: 'synth-001', + diveDate: '2025-06-15 10:30:00', + depth: 25.0, + averageDepth: 18.0, + diveLengthTime: 3600, + diveNumber: '1', + site: 'Test Reef', + location: 'Test Location', + environment: 'Ocean/Sea', + weather: 'Sunny', + fileName: 'Teric[AABB1234]#1 2025-06-15 10-30-00.swlogzp', + tankProfileDataJson: tankJson, + dataBytes3: footerBlob, + ), + ShearwaterTestDive( + diveId: 'synth-002', + diveDate: '2025-06-16 09:00:00', + depth: 18.0, + averageDepth: 12.0, + diveLengthTime: 2400, + diveNumber: '2', + site: 'Test Reef', + location: 'Test Location', + environment: 'Lake', + fileName: 'Teric[AABB1234]#2 2025-06-16 09-00-00.swlogzp', + dataBytes3: footerBlob, + ), + ], + ); + + final parser = ShearwaterCloudParser(); + final payload = await parser.parse( + bytes, + options: const ImportOptions( + sourceApp: SourceApp.shearwater, + format: ImportFormat.shearwaterDb, + ), + ); + + expect(payload.isNotEmpty, isTrue); + + final dives = payload.entitiesOf(ImportEntityType.dives); + expect(dives, hasLength(2)); + expect(dives[0]['importSource'], 'shearwater_cloud'); + expect(dives[0]['importId'], 'synth-001'); + expect(dives[0]['dateTime'], isA()); + expect(dives[0]['maxDepth'], 25.0); + expect(dives[0]['siteName'], 'Test Reef'); + }); + + test('parse extracts unique sites', () async { + final bytes = createShearwaterTestDb( + dives: [ + const ShearwaterTestDive( + diveId: 'site-1', + diveDate: '2025-06-15', + site: 'Reef A', + location: 'FL', + gnssEntryLocation: '25.0,-80.0', + ), + const ShearwaterTestDive( + diveId: 'site-2', + diveDate: '2025-06-16', + site: 'Reef A', + location: 'FL', + ), + const ShearwaterTestDive( + diveId: 'site-3', + diveDate: '2025-06-17', + site: 'Reef B', + ), + ], + ); + + final parser = ShearwaterCloudParser(); + final payload = await parser.parse(bytes); + + final sites = payload.entitiesOf(ImportEntityType.sites); + expect(sites, hasLength(2)); + final names = sites.map((s) => s['name']).toSet(); + expect(names, containsAll(['Reef A', 'Reef B'])); + }); + + test('metadata contains source and dive count', () async { + final bytes = createShearwaterTestDb( + dives: [ + const ShearwaterTestDive(diveId: 'd1', diveDate: '2025-01-01'), + const ShearwaterTestDive(diveId: 'd2', diveDate: '2025-01-02'), + ], + ); + + final parser = ShearwaterCloudParser(); + final payload = await parser.parse(bytes); + + expect(payload.metadata['source'], 'shearwater_cloud'); + expect(payload.metadata['diveCount'], 2); + }); + + test('FFI failure produces single warning then falls back', () async { + final rawData = Uint8List.fromList(List.filled(100, 0)); + final compressed = createCompressedLogData(rawData); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive( + diveId: 'ffi-1', + diveDate: '2025-01-01', + fileName: 'Teric[AABB1234]#1 2025-01-01 10-00-00.swlogzp', + dataBytes1: compressed, + ), + ShearwaterTestDive( + diveId: 'ffi-2', + diveDate: '2025-01-02', + fileName: 'Teric[AABB1234]#2 2025-01-02 10-00-00.swlogzp', + dataBytes1: compressed, + ), + ShearwaterTestDive( + diveId: 'ffi-3', + diveDate: '2025-01-03', + fileName: 'Teric[AABB1234]#3 2025-01-03 10-00-00.swlogzp', + dataBytes1: compressed, + ), + ], + ); + + final parser = ShearwaterCloudParser(); + final payload = await parser.parse(bytes); + + final dives = payload.entitiesOf(ImportEntityType.dives); + expect(dives, hasLength(3)); + // MissingPluginException is caught by the parser on the first dive, + // FFI is disabled, and remaining dives use metadata-only. + // No per-dive "Profile parsing failed" warnings should appear. + final ffiWarnings = payload.warnings.where( + (w) => w.message.contains('Profile parsing failed'), + ); + expect(ffiWarnings, isEmpty); + }); + + group('real fixture', () { + test('parses 28 dives from real database', () async { + final file = File('third_party/shearwater_cloud_database.db'); + if (!file.existsSync()) { + markTestSkipped('Fixture not available'); + return; + } + final dbBytes = file.readAsBytesSync(); + final parser = ShearwaterCloudParser(); + final payload = await parser.parse( + dbBytes, + options: const ImportOptions( + sourceApp: SourceApp.shearwater, + format: ImportFormat.shearwaterDb, + ), + ); + final dives = payload.entitiesOf(ImportEntityType.dives); + expect(dives, hasLength(28)); + }); + }); + }); +} diff --git a/test/features/universal_import/data/services/shearwater_db_reader_test.dart b/test/features/universal_import/data/services/shearwater_db_reader_test.dart new file mode 100644 index 000000000..41bd171f2 --- /dev/null +++ b/test/features/universal_import/data/services/shearwater_db_reader_test.dart @@ -0,0 +1,427 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_db_reader.dart'; + +import 'shearwater_test_helpers.dart'; + +void main() { + group('ShearwaterDbReader', () { + group('isShearwaterCloudDb', () { + test('returns true for valid Shearwater Cloud database', () async { + final bytes = createShearwaterTestDb(); + final result = await ShearwaterDbReader.isShearwaterCloudDb(bytes); + expect(result, isTrue); + }); + + test('returns false for non-SQLite bytes', () async { + final result = await ShearwaterDbReader.isShearwaterCloudDb( + Uint8List.fromList([1, 2, 3, 4]), + ); + expect(result, isFalse); + }); + + test('returns false for empty bytes', () async { + final result = await ShearwaterDbReader.isShearwaterCloudDb( + Uint8List(0), + ); + expect(result, isFalse); + }); + + test('returns false for SQLite DB missing dive_details', () async { + final bytes = createShearwaterTestDb(includeDiveDetails: false); + final result = await ShearwaterDbReader.isShearwaterCloudDb(bytes); + expect(result, isFalse); + }); + + test('returns false for SQLite DB missing log_data', () async { + final bytes = createShearwaterTestDb(includeLogData: false); + final result = await ShearwaterDbReader.isShearwaterCloudDb(bytes); + expect(result, isFalse); + }); + }); + + group('readDives', () { + test('returns empty list for empty database', () async { + final bytes = createShearwaterTestDb(); + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives, isEmpty); + }); + + test('reads dive metadata from dive_details', () async { + final tankJson = jsonEncode({ + 'GasProfiles': [ + {'O2Percent': 32, 'HePercent': 0}, + ], + 'TankData': [ + { + 'StartPressurePSI': '3000', + 'EndPressurePSI': '1500', + 'GasProfile': {'O2Percent': 32, 'HePercent': 0}, + 'DiveTransmitter': {'TankIndex': 0, 'IsOn': true, 'Name': 'T1'}, + 'SurfacePressureMBar': 1013.0, + }, + ], + }); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive( + diveId: 'test-001', + diveDate: '2025-06-15 10:30:00', + depth: 26.8, + averageDepth: 19.4, + diveLengthTime: 1764, + diveNumber: '23', + serialNumber: '69FE56D7', + location: 'Shark River, NJ', + site: 'Maclearie Park', + buddy: 'John Doe', + notes: 'Great dive', + environment: 'Ocean/Sea', + visibility: '30', + weather: 'Sunny', + conditions: 'Current', + airTemperature: '72', + weight: '14', + dress: 'Wet Suit', + apparatus: 'Open Circuit', + thermalComfort: 'Comfortable', + workload: 'Light', + gnssEntryLocation: '40.1234,-74.5678', + tankProfileDataJson: tankJson, + gasNotes: 'EAN32', + gearNotes: 'Perdix AI', + issueNotes: 'None', + endGF99: 0.85, + ), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + + expect(dives, hasLength(1)); + final dive = dives.first; + expect(dive.diveId, 'test-001'); + expect(dive.diveDate, '2025-06-15 10:30:00'); + expect(dive.depth, 26.8); + expect(dive.averageDepth, 19.4); + expect(dive.diveLengthTime, 1764); + expect(dive.diveNumber, '23'); + expect(dive.serialNumber, '69FE56D7'); + expect(dive.location, 'Shark River, NJ'); + expect(dive.site, 'Maclearie Park'); + expect(dive.buddy, 'John Doe'); + expect(dive.notes, 'Great dive'); + expect(dive.environment, 'Ocean/Sea'); + expect(dive.visibility, '30'); + expect(dive.weather, 'Sunny'); + expect(dive.conditions, 'Current'); + expect(dive.airTemperature, '72'); + expect(dive.weight, '14'); + expect(dive.dress, 'Wet Suit'); + expect(dive.apparatus, 'Open Circuit'); + expect(dive.thermalComfort, 'Comfortable'); + expect(dive.workload, 'Light'); + expect(dive.gnssEntryLocation, '40.1234,-74.5678'); + expect(dive.gasNotes, 'EAN32'); + expect(dive.gearNotes, 'Perdix AI'); + expect(dive.issueNotes, 'None'); + expect(dive.endGF99, 0.85); + }); + + test('parses TankProfileData JSON string', () async { + final tankJson = jsonEncode({ + 'TankData': [ + { + 'GasProfile': {'O2Percent': 32, 'HePercent': 0}, + 'DiveTransmitter': {'IsOn': true, 'Name': 'T1'}, + }, + ], + }); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive( + diveId: 'tank-test', + tankProfileDataJson: tankJson, + ), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.tankProfileData, isNotNull); + expect(dives.first.tankProfileData!['TankData'], isList); + }); + + test('parses calculatedValues JSON string', () async { + final calcJson = jsonEncode({'AverageDepth': 15.5, 'MaxDepth': 28.3}); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive( + diveId: 'calc-test', + calculatedValuesJson: calcJson, + ), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.calculatedValues, isNotNull); + expect(dives.first.calculatedValues!['AverageDepth'], 15.5); + }); + + test('decompresses gzip data_bytes_1', () async { + final rawData = Uint8List.fromList(List.filled(256, 0xAB)); + final compressed = createCompressedLogData(rawData); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive(diveId: 'gz-test', dataBytes1: compressed), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.decompressedLogData, isNotNull); + expect(dives.first.decompressedLogData!.length, rawData.length); + expect(dives.first.decompressedLogData, equals(rawData)); + }); + + test( + 'decompresses gzip with zeroed CRC via raw deflate fallback', + () async { + final rawData = Uint8List.fromList(List.filled(256, 0xCD)); + final compressed = createCompressedLogDataWithZeroedCrc(rawData); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive(diveId: 'crc-test', dataBytes1: compressed), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.decompressedLogData, isNotNull); + expect(dives.first.decompressedLogData!.length, rawData.length); + expect(dives.first.decompressedLogData, equals(rawData)); + }, + ); + + test('returns null for data_bytes_1 that is too short', () async { + // 4 prefix + 10 gzip header = 14 minimum; use 10 bytes + final tooShort = Uint8List.fromList(List.filled(10, 0)); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive(diveId: 'short-test', dataBytes1: tooShort), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.decompressedLogData, isNull); + }); + + test('returns null for completely invalid data_bytes_1', () async { + // Long enough but not valid gzip + final invalid = Uint8List.fromList(List.filled(100, 0xFF)); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive(diveId: 'invalid-test', dataBytes1: invalid), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.decompressedLogData, isNull); + }); + + test('parses header JSON from data_bytes_2', () async { + final headerJson = {'SomeHeader': 'value', 'Version': 42}; + final headerBlob = jsonToBlob(headerJson); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive(diveId: 'hdr-test', dataBytes2: headerBlob), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.headerJson, isNotNull); + expect(dives.first.headerJson!['SomeHeader'], 'value'); + expect(dives.first.headerJson!['Version'], 42); + }); + + test('parses footer JSON from data_bytes_3', () async { + final footerJson = {'UnitSystem': 1, 'DiveTimeInSeconds': 1764}; + final footerBlob = jsonToBlob(footerJson); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive(diveId: 'ftr-test', dataBytes3: footerBlob), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.footerJson, isNotNull); + expect(dives.first.footerJson!['UnitSystem'], 1); + expect(dives.first.footerJson!['DiveTimeInSeconds'], 1764); + }); + + test('normalizes empty strings to null', () async { + final bytes = createShearwaterTestDb( + dives: [ + const ShearwaterTestDive( + diveId: 'empty-str', + diveDate: '', + location: '', + site: '', + buddy: '', + notes: '', + ), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + final dive = dives.first; + // Empty strings should be normalized to null by _str() + expect(dive.diveDate, isNull); + expect(dive.location, isNull); + expect(dive.site, isNull); + expect(dive.buddy, isNull); + expect(dive.notes, isNull); + }); + + test('handles null values gracefully', () async { + final bytes = createShearwaterTestDb( + dives: [const ShearwaterTestDive(diveId: 'nulls')], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + final dive = dives.first; + expect(dive.diveId, 'nulls'); + expect(dive.depth, isNull); + expect(dive.averageDepth, isNull); + expect(dive.diveLengthTime, isNull); + expect(dive.decompressedLogData, isNull); + expect(dive.headerJson, isNull); + expect(dive.footerJson, isNull); + expect(dive.tankProfileData, isNull); + expect(dive.calculatedValues, isNull); + }); + + test('reads multiple dives ordered by date', () async { + final bytes = createShearwaterTestDb( + dives: [ + const ShearwaterTestDive( + diveId: 'dive-2', + diveDate: '2025-06-16 09:00:00', + ), + const ShearwaterTestDive( + diveId: 'dive-1', + diveDate: '2025-06-15 10:00:00', + ), + const ShearwaterTestDive( + diveId: 'dive-3', + diveDate: '2025-06-17 08:00:00', + ), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives, hasLength(3)); + expect(dives[0].diveId, 'dive-1'); + expect(dives[1].diveId, 'dive-2'); + expect(dives[2].diveId, 'dive-3'); + }); + + test('handles dive with log_data filename', () async { + final bytes = createShearwaterTestDb( + dives: [ + const ShearwaterTestDive( + diveId: 'fn-test', + fileName: 'Teric[69FE56D7]#23 2025-12-27 14-01-08.swlogzp', + ), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.fileName, contains('Teric')); + expect(dives.first.fileName, contains('.swlogzp')); + }); + + test('handles invalid JSON in TankProfileData gracefully', () async { + final bytes = createShearwaterTestDb( + dives: [ + const ShearwaterTestDive( + diveId: 'bad-json', + tankProfileDataJson: 'not valid json {{{', + ), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.tankProfileData, isNull); + }); + + test('handles invalid JSON in data_bytes_2 gracefully', () async { + final invalidBlob = Uint8List.fromList(utf8.encode('not json')); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive(diveId: 'bad-hdr', dataBytes2: invalidBlob), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.headerJson, isNull); + }); + + test('handles empty BLOB in data_bytes_3 gracefully', () async { + final emptyBlob = Uint8List(0); + + final bytes = createShearwaterTestDb( + dives: [ + ShearwaterTestDive(diveId: 'empty-blob', dataBytes3: emptyBlob), + ], + ); + + final dives = await ShearwaterDbReader.readDives(bytes); + expect(dives.first.footerJson, isNull); + }); + }); + + group('real fixture', () { + late Uint8List dbBytes; + + setUpAll(() { + final file = File('third_party/shearwater_cloud_database.db'); + if (!file.existsSync()) return; + dbBytes = file.readAsBytesSync(); + }); + + test('reads 28 dives from real Shearwater Cloud database', () async { + final file = File('third_party/shearwater_cloud_database.db'); + if (!file.existsSync()) { + markTestSkipped('Fixture not available'); + return; + } + final dives = await ShearwaterDbReader.readDives(dbBytes); + expect(dives, hasLength(28)); + }); + + test('real dives have decompressed log data', () async { + final file = File('third_party/shearwater_cloud_database.db'); + if (!file.existsSync()) { + markTestSkipped('Fixture not available'); + return; + } + final dives = await ShearwaterDbReader.readDives(dbBytes); + final withData = dives + .where((d) => d.decompressedLogData != null) + .toList(); + expect(withData, isNotEmpty); + }); + }); + }); +} diff --git a/test/features/universal_import/data/services/shearwater_dive_mapper_metadata_test.dart b/test/features/universal_import/data/services/shearwater_dive_mapper_metadata_test.dart new file mode 100644 index 000000000..584b26a5f --- /dev/null +++ b/test/features/universal_import/data/services/shearwater_dive_mapper_metadata_test.dart @@ -0,0 +1,538 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_db_reader.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_dive_mapper.dart'; + +void main() { + group('ShearwaterDiveMapper', () { + group('mapDiveMetadata', () { + test('maps core metadata fields', () { + const rawDive = ShearwaterRawDive( + diveId: 'test123', + diveDate: '2025-12-27 14:01:08', + depth: 26.80, + averageDepth: 19.45, + diveLengthTime: 1764, + diveNumber: '23', + serialNumber: '69FE56D7', + location: 'Shark River, NJ, USA', + site: 'Maclearie Park', + buddy: 'John Doe', + notes: 'Great dive', + environment: 'Ocean/Sea', + visibility: '30', + weather: 'Sunny', + conditions: 'Current', + airTemperature: '72', + weight: '14', + dress: 'Wet Suit', + fileName: 'Teric[69FE56D7]#23 2025-12-27 14-01-08.swlogzp', + footerJson: {'UnitSystem': 1, 'DiveTimeInSeconds': 1764}, + tankProfileData: { + 'GasProfiles': [ + {'O2Percent': 32, 'HePercent': 0, 'CircuitMode': 1}, + ], + 'TankData': [ + { + 'StartPressurePSI': '2960', + 'EndPressurePSI': '1088', + 'GasProfile': {'O2Percent': 32, 'HePercent': 0}, + 'DiveTransmitter': {'TankIndex': 0, 'IsOn': true, 'Name': 'T1'}, + 'SurfacePressureMBar': 1015.0, + }, + ], + }, + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + expect(result['importSource'], 'shearwater_cloud'); + expect(result['importId'], 'test123'); + expect(result['dateTime'], isA()); + expect((result['dateTime'] as DateTime).year, 2025); + expect((result['dateTime'] as DateTime).month, 12); + expect((result['dateTime'] as DateTime).day, 27); + expect((result['dateTime'] as DateTime).isUtc, isTrue); + // 26.80 ft = 8.169 m, 19.45 ft = 5.928 m (imperial -> metric) + expect(result['maxDepth'], closeTo(8.169, 0.001)); + expect(result['avgDepth'], closeTo(5.928, 0.001)); + expect(result['runtime'], const Duration(seconds: 1764)); + expect(result['diveNumber'], 23); + expect(result['buddyRefs'], ['John Doe']); + expect(result['siteName'], 'Maclearie Park'); + expect(result['diveComputerModel'], 'Teric'); + expect(result['diveComputerSerial'], '69FE56D7'); + expect(result['notes'], contains('Great dive')); + }); + + test('maps conditions to structured enums', () { + const rawDive = ShearwaterRawDive( + diveId: 'test', + environment: 'Ocean/Sea', + visibility: '30', + weather: 'Sunny', + conditions: 'Current', + footerJson: {'UnitSystem': 1}, + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + expect(result['waterType'], WaterType.salt); + expect(result['visibility'], Visibility.moderate); + expect(result['cloudCover'], CloudCover.clear); + expect(result['currentStrength'], CurrentStrength.moderate); + }); + + test('converts imperial units', () { + const rawDive = ShearwaterRawDive( + diveId: 'test', + depth: 90.0, + averageDepth: 60.0, + airTemperature: '72', + weight: '14', + footerJson: {'UnitSystem': 1}, + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + // 90 ft = 27.432 m + expect(result['maxDepth'], closeTo(27.432, 0.001)); + // 60 ft = 18.288 m + expect(result['avgDepth'], closeTo(18.288, 0.001)); + // 72F = 22.2C + expect(result['airTemp'], closeTo(22.2, 0.1)); + // 14lbs = 6.35kg + expect(result['weightAmount'], closeTo(6.35, 0.01)); + }); + + test('does not convert metric units', () { + const rawDive = ShearwaterRawDive( + diveId: 'test', + depth: 27.0, + averageDepth: 18.0, + airTemperature: '22', + weight: '6', + footerJson: {'UnitSystem': 0}, + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + expect(result['maxDepth'], 27.0); + expect(result['avgDepth'], 18.0); + expect(result['airTemp'], closeTo(22.0, 0.1)); + expect(result['weightAmount'], closeTo(6.0, 0.01)); + }); + + test('includes notes with extra notes appended', () { + const rawDive = ShearwaterRawDive( + diveId: 'test', + notes: 'Great dive', + dress: 'Wet Suit', + footerJson: {'UnitSystem': 0}, + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + final notes = result['notes'] as String; + + expect(notes, contains('Great dive')); + expect(notes, contains('Dress: Wet Suit')); + }); + + test('handles notes when only user notes present', () { + const rawDive = ShearwaterRawDive(diveId: 'test', notes: 'Great dive'); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + expect(result['notes'], 'Great dive'); + }); + + test('handles notes when only extra notes present', () { + const rawDive = ShearwaterRawDive(diveId: 'test', dress: 'Dry Suit'); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + final notes = result['notes'] as String; + + expect(notes, contains('Dress: Dry Suit')); + }); + + test('maps site reference for matching', () { + const rawDive = ShearwaterRawDive( + diveId: 'test', + site: 'Maclearie Park', + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + expect(result['siteName'], 'Maclearie Park'); + expect(result['site'], isA>()); + expect((result['site'] as Map)['name'], 'Maclearie Park'); + expect((result['site'] as Map)['uddfId'], 'Maclearie Park'); + }); + + test('extracts surface pressure from tank data', () { + const rawDive = ShearwaterRawDive( + diveId: 'test', + tankProfileData: { + 'TankData': [ + { + 'StartPressurePSI': '3000', + 'EndPressurePSI': '1000', + 'GasProfile': {'O2Percent': 21, 'HePercent': 0}, + 'DiveTransmitter': {'TankIndex': 0, 'IsOn': true, 'Name': 'T1'}, + 'SurfacePressureMBar': 1013.0, + }, + ], + }, + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + // 1013 mbar = 1.013 bar + expect(result['surfacePressure'], closeTo(1.013, 0.001)); + }); + + test('handles dive with no metadata gracefully', () { + const rawDive = ShearwaterRawDive(diveId: 'empty'); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + expect(result['importSource'], 'shearwater_cloud'); + expect(result['importId'], 'empty'); + expect(result['dateTime'], isNull); + expect(result['maxDepth'], isNull); + expect(result['tanks'], isEmpty); + }); + + test('maps dive computer info from filename', () { + const rawDive = ShearwaterRawDive( + diveId: 'test', + fileName: 'Perdix 2[AABB1234]#5 2025-01-15 09-30-00.swlogzp', + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + expect(result['diveComputerModel'], 'Perdix 2'); + expect(result['diveComputerSerial'], 'AABB1234'); + }); + + test('defaults to oc diveMode when no apparatus set', () { + const rawDive = ShearwaterRawDive(diveId: 'test'); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + expect(result['diveMode'], DiveMode.oc); + }); + + test('maps CCR apparatus to ccr diveMode', () { + const rawDive = ShearwaterRawDive( + diveId: 'test', + apparatus: 'Closed Circuit', + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + expect(result['diveMode'], DiveMode.ccr); + }); + + test('maps SCR apparatus to scr diveMode', () { + const rawDive = ShearwaterRawDive( + diveId: 'test', + apparatus: 'Semi-Closed', + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + + expect(result['diveMode'], DiveMode.scr); + }); + + test('normalizes naive datetime to UTC wall-time', () { + const rawDive = ShearwaterRawDive( + diveId: 'test', + diveDate: '2025-06-15 10:30:00', + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + final dt = result['dateTime'] as DateTime; + + expect(dt.isUtc, isTrue); + expect(dt.hour, 10); + expect(dt.minute, 30); + }); + + test('preserves UTC datetime as-is', () { + const rawDive = ShearwaterRawDive( + diveId: 'test', + diveDate: '2025-06-15T10:30:00Z', + ); + + final result = ShearwaterDiveMapper.mapDiveMetadata(rawDive); + final dt = result['dateTime'] as DateTime; + + expect(dt.isUtc, isTrue); + expect(dt.hour, 10); + expect(dt.minute, 30); + }); + }); + + group('mapTanks', () { + test('maps active tanks with gas mix and pressures', () { + const rawDive = ShearwaterRawDive( + diveId: 'test123', + footerJson: {'UnitSystem': 1}, + tankProfileData: { + 'TankData': [ + { + 'StartPressurePSI': '2960', + 'EndPressurePSI': '1088', + 'GasProfile': {'O2Percent': 32, 'HePercent': 0}, + 'DiveTransmitter': {'TankIndex': 0, 'IsOn': true, 'Name': 'T1'}, + 'SurfacePressureMBar': 1015.0, + }, + { + 'StartPressurePSI': '', + 'EndPressurePSI': '', + 'GasProfile': {'O2Percent': 21, 'HePercent': 0}, + 'DiveTransmitter': { + 'TankIndex': 1, + 'IsOn': false, + 'Name': 'T2', + }, + 'SurfacePressureMBar': 1015.0, + }, + ], + }, + ); + + final tanks = ShearwaterDiveMapper.mapTanks(rawDive); + + expect(tanks, hasLength(1)); + expect((tanks[0]['gasMix'] as GasMix).o2, 32); + expect((tanks[0]['gasMix'] as GasMix).he, 0); + expect(tanks[0]['startPressure'], closeTo(204.1, 0.5)); + expect(tanks[0]['endPressure'], closeTo(75.0, 0.5)); + expect(tanks[0]['name'], 'T1'); + }); + + test('maps multiple active tanks', () { + const rawDive = ShearwaterRawDive( + diveId: 'test', + footerJson: {'UnitSystem': 1}, + tankProfileData: { + 'TankData': [ + { + 'StartPressurePSI': '3000', + 'EndPressurePSI': '1500', + 'GasProfile': {'O2Percent': 32, 'HePercent': 0}, + 'DiveTransmitter': {'TankIndex': 0, 'IsOn': true, 'Name': 'T1'}, + 'SurfacePressureMBar': 1013.0, + }, + { + 'StartPressurePSI': '3000', + 'EndPressurePSI': '2800', + 'GasProfile': {'O2Percent': 100, 'HePercent': 0}, + 'DiveTransmitter': { + 'TankIndex': 1, + 'IsOn': true, + 'Name': 'Deco', + }, + 'SurfacePressureMBar': 1013.0, + }, + ], + }, + ); + + final tanks = ShearwaterDiveMapper.mapTanks(rawDive); + + expect(tanks, hasLength(2)); + expect((tanks[0]['gasMix'] as GasMix).o2, 32); + expect(tanks[0]['name'], 'T1'); + expect((tanks[1]['gasMix'] as GasMix).o2, 100); + expect(tanks[1]['name'], 'Deco'); + }); + + test('maps trimix tank', () { + const rawDive = ShearwaterRawDive( + diveId: 'test', + footerJson: {'UnitSystem': 1}, + tankProfileData: { + 'TankData': [ + { + 'StartPressurePSI': '3000', + 'EndPressurePSI': '1000', + 'GasProfile': {'O2Percent': 21, 'HePercent': 35}, + 'DiveTransmitter': {'TankIndex': 0, 'IsOn': true, 'Name': 'T1'}, + 'SurfacePressureMBar': 1013.0, + }, + ], + }, + ); + + final tanks = ShearwaterDiveMapper.mapTanks(rawDive); + + expect(tanks, hasLength(1)); + expect((tanks[0]['gasMix'] as GasMix).o2, 21); + expect((tanks[0]['gasMix'] as GasMix).he, 35); + }); + + test('returns empty list when no tank data', () { + const rawDive = ShearwaterRawDive(diveId: 'test'); + + final tanks = ShearwaterDiveMapper.mapTanks(rawDive); + + expect(tanks, isEmpty); + }); + + test('returns empty list when TankData is missing', () { + const rawDive = ShearwaterRawDive( + diveId: 'test', + tankProfileData: {'GasProfiles': []}, + ); + + final tanks = ShearwaterDiveMapper.mapTanks(rawDive); + + expect(tanks, isEmpty); + }); + + test('handles tanks with missing pressure values', () { + const rawDive = ShearwaterRawDive( + diveId: 'test', + footerJson: {'UnitSystem': 1}, + tankProfileData: { + 'TankData': [ + { + 'StartPressurePSI': '', + 'EndPressurePSI': '', + 'GasProfile': {'O2Percent': 32, 'HePercent': 0}, + 'DiveTransmitter': {'TankIndex': 0, 'IsOn': true, 'Name': 'T1'}, + 'SurfacePressureMBar': 1013.0, + }, + ], + }, + ); + + final tanks = ShearwaterDiveMapper.mapTanks(rawDive); + + expect(tanks, hasLength(1)); + expect(tanks[0]['startPressure'], isNull); + expect(tanks[0]['endPressure'], isNull); + }); + }); + + group('mapSites', () { + test('maps site from location and site fields', () { + const rawDive = ShearwaterRawDive( + diveId: 'test123', + location: 'Shark River, NJ, USA', + site: 'Maclearie Park', + ); + + final sites = ShearwaterDiveMapper.mapSites([rawDive]); + + expect(sites, hasLength(1)); + expect(sites[0]['name'], 'Maclearie Park'); + expect(sites[0]['uddfId'], 'Maclearie Park'); + }); + + test('deduplicates sites by name', () { + final dives = [ + const ShearwaterRawDive( + diveId: '1', + site: 'Same Site', + location: 'NJ', + ), + const ShearwaterRawDive( + diveId: '2', + site: 'Same Site', + location: 'NJ', + ), + const ShearwaterRawDive( + diveId: '3', + site: 'Other Site', + location: 'FL', + ), + ]; + + final sites = ShearwaterDiveMapper.mapSites(dives); + + expect(sites, hasLength(2)); + final names = sites.map((s) => s['name']).toSet(); + expect(names, containsAll(['Same Site', 'Other Site'])); + }); + + test('skips dives with no site name', () { + final dives = [ + const ShearwaterRawDive(diveId: '1', site: 'Named Site'), + const ShearwaterRawDive(diveId: '2'), + const ShearwaterRawDive(diveId: '3', site: null), + ]; + + final sites = ShearwaterDiveMapper.mapSites(dives); + + expect(sites, hasLength(1)); + expect(sites[0]['name'], 'Named Site'); + }); + + test('returns empty list when no dives have sites', () { + final dives = [ + const ShearwaterRawDive(diveId: '1'), + const ShearwaterRawDive(diveId: '2'), + ]; + + final sites = ShearwaterDiveMapper.mapSites(dives); + + expect(sites, isEmpty); + }); + + test('includes location as notes', () { + const rawDive = ShearwaterRawDive( + diveId: 'test', + site: 'My Site', + location: 'Some Location', + ); + + final sites = ShearwaterDiveMapper.mapSites([rawDive]); + + expect(sites[0]['notes'], 'Some Location'); + }); + + test('parses GNSS entry location to lat/lon', () { + const rawDive = ShearwaterRawDive( + diveId: 'test', + site: 'GPS Site', + gnssEntryLocation: '40.1234,-74.5678', + ); + + final sites = ShearwaterDiveMapper.mapSites([rawDive]); + + expect(sites[0]['latitude'], closeTo(40.1234, 0.0001)); + expect(sites[0]['longitude'], closeTo(-74.5678, 0.0001)); + }); + }); + + group('_toDouble fallback', () { + test('string-to-double fallback via mapTanks', () { + // _toDouble is private but exercised through mapTanks when + // GasProfile values are strings instead of nums. + const rawDive = ShearwaterRawDive( + diveId: 'test', + tankProfileData: { + 'TankData': [ + { + 'StartPressurePSI': '3000', + 'EndPressurePSI': '1000', + 'GasProfile': {'O2Percent': '32.0', 'HePercent': '10.0'}, + 'DiveTransmitter': {'TankIndex': 0, 'IsOn': true, 'Name': 'T1'}, + }, + ], + }, + ); + + final tanks = ShearwaterDiveMapper.mapTanks(rawDive); + expect(tanks, hasLength(1)); + expect((tanks[0]['gasMix'] as GasMix).o2, 32.0); + expect((tanks[0]['gasMix'] as GasMix).he, 10.0); + }); + }); + }); +} diff --git a/test/features/universal_import/data/services/shearwater_dive_mapper_profile_test.dart b/test/features/universal_import/data/services/shearwater_dive_mapper_profile_test.dart new file mode 100644 index 000000000..1caf6600c --- /dev/null +++ b/test/features/universal_import/data/services/shearwater_dive_mapper_profile_test.dart @@ -0,0 +1,449 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_enums.dart'; +import 'package:submersion/features/universal_import/data/models/import_warning.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_db_reader.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_dive_mapper.dart'; + +void main() { + group('ShearwaterDiveMapper', () { + group('mapDive', () { + test('falls back to metadata when no decompressed data', () async { + const rawDive = ShearwaterRawDive( + diveId: 'ffi-test', + diveDate: '2025-06-15 10:30:00', + depth: 20.0, + diveLengthTime: 3000, + ); + + final warnings = []; + final result = await ShearwaterDiveMapper.mapDive( + rawDive, + warnings: warnings, + ); + + expect(result['importSource'], 'shearwater_cloud'); + expect(result['importId'], 'ffi-test'); + expect(result['maxDepth'], 20.0); + // No warning expected because there was no decompressed data to parse + expect(warnings, isEmpty); + }); + + test('rethrows platform exception from FFI', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + // Provide decompressed data so it attempts FFI parsing. + // In the test environment, the Pigeon channel throws a + // PlatformException (channel-error). Platform-level errors are + // rethrown so the parser can detect FFI is unavailable. + final rawDive = ShearwaterRawDive( + diveId: 'ffi-test', + diveDate: '2025-06-15 10:30:00', + depth: 20.0, + diveLengthTime: 3000, + fileName: 'Teric[AABB1234]#10 2025-06-15 10-30-00.swlogzp', + decompressedLogData: Uint8List.fromList(List.filled(100, 0)), + ); + + expect( + () => ShearwaterDiveMapper.mapDive(rawDive), + throwsA(isA()), + ); + }); + + test('returns metadata only when model is unknown', () async { + final rawDive = ShearwaterRawDive( + diveId: 'test-unknown', + fileName: 'UnknownModel[ABCD]#1 2025-1-1 0-0-0.swlogzp', + decompressedLogData: Uint8List.fromList([1, 2, 3]), + ); + final warnings = []; + final result = await ShearwaterDiveMapper.mapDive( + rawDive, + warnings: warnings, + ); + expect(result['profile'], isEmpty); + expect(warnings, isNotEmpty); + expect(warnings.first.message, contains('Could not determine')); + expect(warnings.first.severity, ImportWarningSeverity.warning); + expect(warnings.first.entityType, ImportEntityType.dives); + }); + }); + + group('mergeWithParsedDive', () { + test('overrides depth/duration from parsed data', () { + final baseMap = { + 'maxDepth': 10.0, + 'avgDepth': 5.0, + 'runtime': const Duration(seconds: 100), + 'profile': >[], + }; + final parsed = pigeon.ParsedDive( + fingerprint: 'abc', + dateTimeYear: 2025, + dateTimeMonth: 12, + dateTimeDay: 27, + dateTimeHour: 14, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 26.8, + avgDepthMeters: 19.4, + durationSeconds: 1764, + samples: [], + tanks: [], + gasMixes: [], + events: [], + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['maxDepth'], 26.8); + expect(result['avgDepth'], 19.4); + expect((result['runtime'] as Duration).inSeconds, 1764); + }); + + test('adds deco algorithm and GF from parsed data', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 0, + avgDepthMeters: 0, + durationSeconds: 0, + samples: [], + tanks: [], + gasMixes: [], + events: [], + decoAlgorithm: 'buhlmann', + gfLow: 30, + gfHigh: 70, + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['decoAlgorithm'], 'buhlmann'); + expect(result['gradientFactorLow'], 30); + expect(result['gradientFactorHigh'], 70); + }); + + test('does not add deco fields when absent in parsed data', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 0, + avgDepthMeters: 0, + durationSeconds: 0, + samples: [], + tanks: [], + gasMixes: [], + events: [], + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result.containsKey('decoAlgorithm'), isFalse); + expect(result.containsKey('gradientFactorLow'), isFalse); + expect(result.containsKey('gradientFactorHigh'), isFalse); + }); + + test('maps dive mode from parsed data', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 0, + avgDepthMeters: 0, + durationSeconds: 0, + samples: [], + tanks: [], + gasMixes: [], + events: [], + diveMode: 'ccr', + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['diveMode'], DiveMode.ccr); + }); + + test('maps scr dive mode from parsed data', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 0, + avgDepthMeters: 0, + durationSeconds: 0, + samples: [], + tanks: [], + gasMixes: [], + events: [], + diveMode: 'scr', + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['diveMode'], DiveMode.scr); + }); + + test('maps unknown dive mode to oc', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 0, + avgDepthMeters: 0, + durationSeconds: 0, + samples: [], + tanks: [], + gasMixes: [], + events: [], + diveMode: 'gauge', + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['diveMode'], DiveMode.oc); + }); + + test('builds profile samples with all sensor data', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 20, + avgDepthMeters: 10, + durationSeconds: 600, + samples: [ + pigeon.ProfileSample( + timeSeconds: 10, + depthMeters: 5.0, + temperatureCelsius: 22.0, + pressureBar: 200.0, + setpoint: 1.3, + ppo2: 1.1, + heartRate: 80, + cns: 5.0, + rbt: 60, + tts: 120, + decoType: 0, + decoTime: 99, + decoDepth: 3.0, + ), + pigeon.ProfileSample(timeSeconds: 20, depthMeters: 10.0), + ], + tanks: [], + gasMixes: [], + events: [], + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + final profile = result['profile'] as List; + expect(profile, hasLength(2)); + + final s1 = profile[0] as Map; + expect(s1['timestamp'], 10); + expect(s1['depth'], 5.0); + expect(s1['temperature'], 22.0); + expect(s1['pressure'], 200.0); + expect(s1['setpoint'], 1.3); + expect(s1['ppO2'], 1.1); + expect(s1['heartRate'], 80); + expect(s1['cns'], 5.0); + expect(s1['rbt'], 60); + expect(s1['tts'], 120); + expect(s1['decoType'], 0); + expect(s1.containsKey('ceiling'), isFalse); + expect(s1['ndl'], 99); + + // Second sample has only depth -- no optional fields + final s2 = profile[1] as Map; + expect(s2['timestamp'], 20); + expect(s2['depth'], 10.0); + expect(s2.containsKey('temperature'), isFalse); + expect(s2.containsKey('pressure'), isFalse); + expect(s2.containsKey('setpoint'), isFalse); + expect(s2.containsKey('ppO2'), isFalse); + expect(s2.containsKey('heartRate'), isFalse); + expect(s2.containsKey('cns'), isFalse); + expect(s2.containsKey('rbt'), isFalse); + expect(s2.containsKey('tts'), isFalse); + expect(s2.containsKey('decoType'), isFalse); + expect(s2.containsKey('ceiling'), isFalse); + expect(s2.containsKey('ndl'), isFalse); + }); + + test('maps deco stop samples with ceiling and no ndl', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 30, + avgDepthMeters: 20, + durationSeconds: 1800, + samples: [ + pigeon.ProfileSample( + timeSeconds: 10, + depthMeters: 25.0, + decoType: 2, + decoTime: 180, + decoDepth: 6.0, + ), + ], + tanks: [], + gasMixes: [], + events: [], + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + final profile = result['profile'] as List; + final s1 = profile[0] as Map; + expect(s1['decoType'], 2); + expect(s1['ceiling'], 6.0); + expect(s1.containsKey('ndl'), isFalse); + }); + + test('extracts water temp from samples when not in metadata', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 10, + avgDepthMeters: 5, + durationSeconds: 300, + samples: [ + pigeon.ProfileSample( + timeSeconds: 10, + depthMeters: 5.0, + temperatureCelsius: 22.0, + ), + pigeon.ProfileSample( + timeSeconds: 20, + depthMeters: 8.0, + temperatureCelsius: 20.0, + ), + ], + tanks: [], + gasMixes: [], + events: [], + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['waterTemp'], 20.0); // min temperature + }); + + test('does not override existing waterTemp', () { + final baseMap = { + 'waterTemp': 25.0, + 'profile': >[], + }; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 10, + avgDepthMeters: 5, + durationSeconds: 300, + samples: [ + pigeon.ProfileSample( + timeSeconds: 10, + depthMeters: 5.0, + temperatureCelsius: 20.0, + ), + ], + tanks: [], + gasMixes: [], + events: [], + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['waterTemp'], 25.0); // unchanged + }); + + test('does not set waterTemp when no temperature samples exist', () { + final baseMap = {'profile': >[]}; + final parsed = pigeon.ParsedDive( + fingerprint: '', + dateTimeYear: 2025, + dateTimeMonth: 1, + dateTimeDay: 1, + dateTimeHour: 0, + dateTimeMinute: 0, + dateTimeSecond: 0, + maxDepthMeters: 10, + avgDepthMeters: 5, + durationSeconds: 300, + samples: [pigeon.ProfileSample(timeSeconds: 10, depthMeters: 5.0)], + tanks: [], + gasMixes: [], + events: [], + ); + final result = ShearwaterDiveMapper.mergeWithParsedDive( + baseMap, + parsed, + ); + expect(result['waterTemp'], isNull); + }); + }); + }); +} diff --git a/test/features/universal_import/data/services/shearwater_filename_parser_test.dart b/test/features/universal_import/data/services/shearwater_filename_parser_test.dart new file mode 100644 index 000000000..efd3749cd --- /dev/null +++ b/test/features/universal_import/data/services/shearwater_filename_parser_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_filename_parser.dart'; + +void main() { + group('ShearwaterFilenameParser', () { + group('parseFilename', () { + test('extracts Teric model and serial', () { + final result = ShearwaterFilenameParser.parse( + 'Teric[8629AC48]#1 2025-9-20 7-42-35.swlogzp', + ); + expect(result.model, 'Teric'); + expect(result.serial, '8629AC48'); + expect(result.diveNumber, 1); + }); + + test('extracts Perdix model and serial', () { + final result = ShearwaterFilenameParser.parse( + 'Perdix[ABCD1234]#15 2025-12-01 10-30-00.swlogzp', + ); + expect(result.model, 'Perdix'); + expect(result.serial, 'ABCD1234'); + expect(result.diveNumber, 15); + }); + + test('extracts Petrel 3 with space in name', () { + final result = ShearwaterFilenameParser.parse( + 'Petrel 3[11223344]#7 2025-6-15 14-00-00.swlogzp', + ); + expect(result.model, 'Petrel 3'); + expect(result.serial, '11223344'); + expect(result.diveNumber, 7); + }); + + test('extracts Peregrine', () { + final result = ShearwaterFilenameParser.parse( + 'Peregrine[DEADBEEF]#100 2025-1-1 0-0-0.swlogzp', + ); + expect(result.model, 'Peregrine'); + expect(result.serial, 'DEADBEEF'); + expect(result.diveNumber, 100); + }); + + test('returns unknown for unrecognized format', () { + final result = ShearwaterFilenameParser.parse('random_file.db'); + expect(result.model, isNull); + expect(result.serial, isNull); + expect(result.diveNumber, isNull); + }); + + test('returns unknown for empty string', () { + final result = ShearwaterFilenameParser.parse(''); + expect(result.model, isNull); + expect(result.serial, isNull); + }); + }); + + group('vendorProduct', () { + test('maps known models to vendor/product', () { + expect(ShearwaterFilenameParser.vendorProduct('Teric'), ( + 'Shearwater', + 'Teric', + )); + expect(ShearwaterFilenameParser.vendorProduct('Perdix'), ( + 'Shearwater', + 'Perdix', + )); + expect(ShearwaterFilenameParser.vendorProduct('Petrel 3'), ( + 'Shearwater', + 'Petrel 3', + )); + }); + + test('returns null for unknown model', () { + expect(ShearwaterFilenameParser.vendorProduct('Unknown'), isNull); + }); + }); + }); +} diff --git a/test/features/universal_import/data/services/shearwater_test_helpers.dart b/test/features/universal_import/data/services/shearwater_test_helpers.dart new file mode 100644 index 000000000..2dd2141f6 --- /dev/null +++ b/test/features/universal_import/data/services/shearwater_test_helpers.dart @@ -0,0 +1,257 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:sqlite3/sqlite3.dart'; + +/// Creates a minimal Shearwater Cloud SQLite database as bytes. +/// +/// This allows tests to run without the real fixture file (which is not +/// committed to git). The resulting bytes can be passed directly to +/// [ShearwaterDbReader.isShearwaterCloudDb] and [readDives]. +Uint8List createShearwaterTestDb({ + List dives = const [], + bool includeDiveDetails = true, + bool includeLogData = true, +}) { + final tempPath = + '${Directory.systemTemp.path}/sw_test_${DateTime.now().millisecondsSinceEpoch}.db'; + final db = sqlite3.open(tempPath); + try { + if (includeDiveDetails) { + db.execute(''' + CREATE TABLE dive_details ( + DiveId TEXT PRIMARY KEY, + DiveDate TEXT, + Depth REAL, + AverageDepth REAL, + DiveLengthTime INTEGER, + DiveNumber TEXT, + SerialNumber TEXT, + Location TEXT, + Site TEXT, + Buddy TEXT, + Notes TEXT, + Environment TEXT, + Visibility TEXT, + Weather TEXT, + Conditions TEXT, + AirTemperature TEXT, + Weight TEXT, + Dress TEXT, + Apparatus TEXT, + ThermalComfort TEXT, + Workload TEXT, + Problems TEXT, + Malfunctions TEXT, + Symptoms TEXT, + GnssEntryLocation TEXT, + GnssExitLocation TEXT, + TankProfileData TEXT, + GasNotes TEXT, + GearNotes TEXT, + IssueNotes TEXT, + EndGF99 REAL + ) + '''); + } + + if (includeLogData) { + db.execute(''' + CREATE TABLE log_data ( + log_id TEXT PRIMARY KEY, + file_name TEXT, + data_bytes_1 BLOB, + data_bytes_2 BLOB, + data_bytes_3 BLOB, + calculated_values_from_samples TEXT + ) + '''); + } + + for (final dive in dives) { + if (includeDiveDetails) { + db.execute( + '''INSERT INTO dive_details ( + DiveId, DiveDate, Depth, AverageDepth, DiveLengthTime, + DiveNumber, SerialNumber, Location, Site, Buddy, Notes, + Environment, Visibility, Weather, Conditions, + AirTemperature, Weight, Dress, Apparatus, + ThermalComfort, Workload, Problems, Malfunctions, Symptoms, + GnssEntryLocation, GnssExitLocation, TankProfileData, + GasNotes, GearNotes, IssueNotes, EndGF99 + ) VALUES ( + ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, ?, ?, ?, + ?, ?, ?, + ?, ?, ?, ? + )''', + [ + dive.diveId, + dive.diveDate, + dive.depth, + dive.averageDepth, + dive.diveLengthTime, + dive.diveNumber, + dive.serialNumber, + dive.location, + dive.site, + dive.buddy, + dive.notes, + dive.environment, + dive.visibility, + dive.weather, + dive.conditions, + dive.airTemperature, + dive.weight, + dive.dress, + dive.apparatus, + dive.thermalComfort, + dive.workload, + dive.problems, + dive.malfunctions, + dive.symptoms, + dive.gnssEntryLocation, + dive.gnssExitLocation, + dive.tankProfileDataJson, + dive.gasNotes, + dive.gearNotes, + dive.issueNotes, + dive.endGF99, + ], + ); + } + + if (includeLogData) { + db.execute( + '''INSERT INTO log_data ( + log_id, file_name, data_bytes_1, data_bytes_2, + data_bytes_3, calculated_values_from_samples + ) VALUES (?, ?, ?, ?, ?, ?)''', + [ + dive.diveId, + dive.fileName, + dive.dataBytes1, + dive.dataBytes2, + dive.dataBytes3, + dive.calculatedValuesJson, + ], + ); + } + } + } finally { + db.dispose(); + } + + final bytes = File(tempPath).readAsBytesSync(); + File(tempPath).deleteSync(); + return bytes; +} + +/// Compresses [rawData] as gzip with the 4-byte LE length prefix that +/// Shearwater Cloud uses for data_bytes_1. +Uint8List createCompressedLogData(Uint8List rawData) { + final compressed = GZipCodec().encode(rawData); + final length = ByteData(4)..setUint32(0, rawData.length, Endian.little); + return Uint8List.fromList([...length.buffer.asUint8List(), ...compressed]); +} + +/// Creates gzip data with zeroed CRC32/ISIZE trailer (triggers raw deflate +/// fallback in the reader). +Uint8List createCompressedLogDataWithZeroedCrc(Uint8List rawData) { + final normal = createCompressedLogData(rawData); + // Zero out the last 8 bytes (CRC32 + ISIZE) of the gzip stream + // The 4-byte prefix is not part of gzip, so gzip starts at index 4 + final result = Uint8List.fromList(normal); + for (var i = result.length - 8; i < result.length; i++) { + result[i] = 0; + } + return result; +} + +/// Encodes a JSON map to a UTF-8 BLOB (for data_bytes_2 / data_bytes_3). +Uint8List jsonToBlob(Map json) { + return Uint8List.fromList(utf8.encode(jsonEncode(json))); +} + +/// Test data for a single Shearwater dive row. +class ShearwaterTestDive { + final String diveId; + final String? diveDate; + final double? depth; + final double? averageDepth; + final int? diveLengthTime; + final String? diveNumber; + final String? serialNumber; + final String? location; + final String? site; + final String? buddy; + final String? notes; + final String? environment; + final String? visibility; + final String? weather; + final String? conditions; + final String? airTemperature; + final String? weight; + final String? dress; + final String? apparatus; + final String? thermalComfort; + final String? workload; + final String? problems; + final String? malfunctions; + final String? symptoms; + final String? gnssEntryLocation; + final String? gnssExitLocation; + final String? tankProfileDataJson; + final String? gasNotes; + final String? gearNotes; + final String? issueNotes; + final double? endGF99; + final String? fileName; + final Uint8List? dataBytes1; + final Uint8List? dataBytes2; + final Uint8List? dataBytes3; + final String? calculatedValuesJson; + + const ShearwaterTestDive({ + required this.diveId, + this.diveDate, + this.depth, + this.averageDepth, + this.diveLengthTime, + this.diveNumber, + this.serialNumber, + this.location, + this.site, + this.buddy, + this.notes, + this.environment, + this.visibility, + this.weather, + this.conditions, + this.airTemperature, + this.weight, + this.dress, + this.apparatus, + this.thermalComfort, + this.workload, + this.problems, + this.malfunctions, + this.symptoms, + this.gnssEntryLocation, + this.gnssExitLocation, + this.tankProfileDataJson, + this.gasNotes, + this.gearNotes, + this.issueNotes, + this.endGF99, + this.fileName, + this.dataBytes1, + this.dataBytes2, + this.dataBytes3, + this.calculatedValuesJson, + }); +} diff --git a/test/features/universal_import/data/services/shearwater_value_mapper_test.dart b/test/features/universal_import/data/services/shearwater_value_mapper_test.dart new file mode 100644 index 000000000..dd535b4f6 --- /dev/null +++ b/test/features/universal_import/data/services/shearwater_value_mapper_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/constants/enums.dart'; +import 'package:submersion/features/universal_import/data/services/shearwater_value_mapper.dart'; + +void main() { + group('ShearwaterValueMapper', () { + group('unit conversions', () { + test('converts PSI to bar', () { + expect(ShearwaterValueMapper.psiToBar(2960), closeTo(204.1, 0.1)); + }); + test('converts Fahrenheit to Celsius', () { + expect( + ShearwaterValueMapper.fahrenheitToCelsius(72), + closeTo(22.2, 0.1), + ); + expect(ShearwaterValueMapper.fahrenheitToCelsius(32), closeTo(0, 0.1)); + }); + test('converts lbs to kg', () { + expect(ShearwaterValueMapper.lbsToKg(14), closeTo(6.35, 0.01)); + }); + test('converts feet to meters', () { + expect(ShearwaterValueMapper.feetToMeters(30), closeTo(9.14, 0.01)); + }); + test('converts mbar to bar', () { + expect(ShearwaterValueMapper.mbarToBar(1015), closeTo(1.015, 0.001)); + }); + }); + + group('conditions mapping', () { + test('maps environment to waterType', () { + expect(ShearwaterValueMapper.mapWaterType('Ocean/Sea'), WaterType.salt); + expect(ShearwaterValueMapper.mapWaterType('Pool'), WaterType.fresh); + expect(ShearwaterValueMapper.mapWaterType('Lake'), WaterType.fresh); + expect(ShearwaterValueMapper.mapWaterType(null), isNull); + expect(ShearwaterValueMapper.mapWaterType(''), isNull); + }); + test('maps Brackish to WaterType.brackish', () { + expect( + ShearwaterValueMapper.mapWaterType('Brackish'), + WaterType.brackish, + ); + }); + test('maps weather to cloudCover', () { + expect(ShearwaterValueMapper.mapCloudCover('Sunny'), CloudCover.clear); + expect( + ShearwaterValueMapper.mapCloudCover('Cloudy'), + CloudCover.mostlyCloudy, + ); + expect(ShearwaterValueMapper.mapCloudCover('Windy'), isNull); + }); + test('maps conditions to currentStrength', () { + expect( + ShearwaterValueMapper.mapCurrentStrength('Current'), + CurrentStrength.moderate, + ); + expect(ShearwaterValueMapper.mapCurrentStrength('Surge'), isNull); + expect(ShearwaterValueMapper.mapCurrentStrength(null), isNull); + }); + test('maps visibility to enum', () { + expect( + ShearwaterValueMapper.mapVisibility('100', isImperial: true), + Visibility.excellent, + ); + expect( + ShearwaterValueMapper.mapVisibility('30', isImperial: true), + Visibility.moderate, + ); + expect( + ShearwaterValueMapper.mapVisibility('10', isImperial: true), + Visibility.poor, + ); + expect( + ShearwaterValueMapper.mapVisibility('30', isImperial: false), + Visibility.excellent, + ); + expect(ShearwaterValueMapper.mapVisibility(null), isNull); + }); + }); + + group('buildExtraNotes', () { + test('collects unmapped fields into structured notes', () { + final notes = ShearwaterValueMapper.buildExtraNotes( + weather: 'Windy', + conditions: 'Surge', + dress: 'Wet Suit', + thermalComfort: 'Warm/Neutral', + workload: 'Resting', + ); + expect(notes, contains('[Shearwater Cloud]')); + expect(notes, contains('Weather: Windy')); + expect(notes, contains('Dress: Wet Suit')); + }); + test('returns null when no extra fields present', () { + final notes = ShearwaterValueMapper.buildExtraNotes(); + expect(notes, isNull); + }); + test('includes gasNotes in extra notes', () { + final notes = ShearwaterValueMapper.buildExtraNotes(gasNotes: 'EAN32'); + expect(notes, contains('Gas Notes: EAN32')); + }); + test('includes issueNotes in extra notes', () { + final notes = ShearwaterValueMapper.buildExtraNotes( + issueNotes: 'Mask fog', + ); + expect(notes, contains('Issue Notes: Mask fog')); + }); + test('includes both gasNotes and issueNotes together', () { + final notes = ShearwaterValueMapper.buildExtraNotes( + gasNotes: 'EAN32', + issueNotes: 'Mask fog', + ); + expect(notes, contains('Gas Notes: EAN32')); + expect(notes, contains('Issue Notes: Mask fog')); + }); + }); + }); +}