Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions SUUNTO_NAUTIC.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ Then: **Import → dive computer → Bluetooth scan.** The watch advertises
`Suunto Nautic <serial>` / `Suunto Ocean <serial>` and shows up as
**Suunto / Nautic** (or Ocean).

**Import from a file** (Transfer → Import data → pick the `.bin`): the raw
`SBEM0103` log a download stores — the decompressed profile + `/Summary` —
is recognised automatically and parsed through the same native
libdivecomputer path a live download uses. The watch's own event wording
comes through as labelled bookmarks (the download path also types them).
Useful for replaying a capture someone sent you, or re-importing without
the watch to hand.

## Known limitations

- The tank-transmitter serial the Suunto app displays isn't shown (its value is
Expand Down
132 changes: 132 additions & 0 deletions lib/features/dive_computer/data/services/raw_log_file.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import 'dart:typed_data';

/// A raw dive-computer log/dump file picked for import.
///
/// This is the offline counterpart of a Bluetooth download: instead of the
/// native layer streaming a dive's bytes off the watch, the diver hands us a
/// file that already holds them. The file is split into one blob per dive,
/// each of which is fed to `DiveComputerHostApi.parseRawDiveData` exactly as
/// the reparse path feeds a stored `rawData` blob.
///
/// Only the Suunto "Vaasa" generation (Nautic / Nautic S / Ocean) is
/// recognised today. Their logs are `SBEM0103` records: a decompressed
/// profile stream, optionally followed by the `/Summary` record (gradient
/// factors, gas mixes, ppO2 ceiling), which is the exact byte layout
/// libdivecomputer's `suunto_nautic` driver stores per dive. A classic
/// flat memory image from another computer is a separate container kind and
/// is not handled yet.
enum RawLogContainer {
/// One or more Suunto Nautic/Ocean dive logs as decompressed `SBEM0103`
/// records. Each dive is a profile record optionally followed by its
/// `/Summary` record.
suuntoNauticSbem,
}

/// The `SBEM0103` magic that opens every decompressed Suunto Nautic record
/// (profile and `/Summary` alike).
final Uint8List kSbemMagic = Uint8List.fromList('SBEM0103'.codeUnits);

/// Result of identifying and splitting a raw log file.
class RawLogFile {
const RawLogFile({
required this.container,
required this.vendor,
required this.product,
required this.model,
required this.records,
this.leadingJunkBytes = 0,
});

/// Which on-disk layout this file is.
final RawLogContainer container;

/// libdivecomputer descriptor vendor for [records], e.g. `Suunto`.
final String vendor;

/// libdivecomputer descriptor product, e.g. `Nautic`. The Nautic and Ocean
/// descriptors share one parser, so `Nautic` is passed for both and the
/// exact unit is refined later from the parsed data / device info.
final String product;

/// libdivecomputer model number, or 0 to let the native layer resolve the
/// descriptor by vendor + product name.
final int model;

/// The `SBEM0103` records in file order. A dive is one profile record,
/// usually followed by its `/Summary` record; [RawLogImportService] decides
/// how to group them.
final List<Uint8List> records;

/// Bytes skipped before the first recognised record (a capture wrapper,
/// a stray header). Non-zero is worth surfacing as a warning.
final int leadingJunkBytes;
}

/// Identifies a picked file and splits it into raw records, without touching
/// the native parser. Grouping records into dives and parsing them is
/// [RawLogImportService]'s job.
class RawLogFileReader {
const RawLogFileReader();

/// Returns the parsed structure of [bytes], or null when the file is not a
/// recognised raw dive-computer log.
RawLogFile? read(Uint8List bytes) {
final firstMagic = _indexOf(bytes, kSbemMagic, 0);
if (firstMagic < 0) return null;

// Every SBEM0103 boundary starts a new record. The profile record's
// header carries its own length, but splitting on the magic is enough:
// rejoining consecutive records reproduces the original contiguous blob
// the driver would have stored, and the parser stops at the profile
// length regardless of what follows.
final offsets = _allIndexesOf(bytes, kSbemMagic, firstMagic);
final records = <Uint8List>[];
for (var i = 0; i < offsets.length; i++) {
final start = offsets[i];
final end = i + 1 < offsets.length ? offsets[i + 1] : bytes.length;
records.add(Uint8List.sublistView(bytes, start, end));
}

return RawLogFile(
container: RawLogContainer.suuntoNauticSbem,
vendor: 'Suunto',
product: 'Nautic',
model: 0,
records: records,
leadingJunkBytes: firstMagic,
);
}

/// True when [bytes] looks like a raw dive-computer log we can import.
bool looksSupported(Uint8List bytes) => read(bytes) != null;

static int _indexOf(Uint8List haystack, Uint8List needle, int from) {
if (needle.isEmpty || haystack.length < needle.length) return -1;
final last = haystack.length - needle.length;
for (var i = from < 0 ? 0 : from; i <= last; i++) {
var match = true;
for (var j = 0; j < needle.length; j++) {
if (haystack[i + j] != needle[j]) {
match = false;
break;
}
}
if (match) return i;
}
return -1;
}

static List<int> _allIndexesOf(
Uint8List haystack,
Uint8List needle,
int from,
) {
final out = <int>[];
var i = _indexOf(haystack, needle, from);
while (i >= 0) {
out.add(i);
i = _indexOf(haystack, needle, i + needle.length);
}
return out;
}
}
164 changes: 164 additions & 0 deletions lib/features/dive_computer/data/services/raw_log_import_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import 'dart:typed_data';

import 'package:flutter/services.dart'
show MissingPluginException, PlatformException;
import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon;

import 'package:submersion/features/dive_computer/data/services/parsed_dive_mapper.dart';
import 'package:submersion/features/dive_computer/data/services/raw_log_file.dart';
import 'package:submersion/features/dive_computer/domain/entities/downloaded_dive.dart';
import 'package:submersion/features/universal_import/data/services/raw_profile_sanity_check.dart';

/// The Pigeon signature `DiveComputerHostApi.parseRawDiveData` exposes, kept
/// injectable so the service is unit-testable without the platform channel.
typedef RawDiveParseFn =
Future<pigeon.ParsedDive> Function(
String vendor,
String product,
int model,
Uint8List data,
);

/// Outcome of importing a raw dive-computer log file.
class RawLogImportOutcome {
const RawLogImportOutcome({
required this.dives,
required this.recordsRead,
required this.divesFailed,
this.warnings = const [],
});

/// The dives recovered from the file, oldest first, ready to hand to
/// `DiveImportService.importDives` exactly like a Bluetooth download.
final List<DownloadedDive> dives;

/// How many `SBEM0103` records the file held.
final int recordsRead;

/// Record groups that a parse could not turn into a plausible dive.
final int divesFailed;

final List<String> warnings;

bool get isEmpty => dives.isEmpty;
}

/// Turns a picked raw dive-computer log file into [DownloadedDive]s.
///
/// The file is identified and split by [RawLogFileReader]; this service
/// groups the records into dives and runs each group through the native
/// libdivecomputer parser, reusing [parsedDiveToDownloaded] so a file import
/// and a Bluetooth download produce byte-identical dive records (same events
/// with their exact Suunto labels, same tank/gas linkage, same fingerprint).
class RawLogImportService {
RawLogImportService({
required RawDiveParseFn parseFn,
RawLogFileReader reader = const RawLogFileReader(),
bool trimTankPressureAtSurfacing = true,
}) : _parseFn = parseFn,
_reader = reader,
_trimAtSurfacing = trimTankPressureAtSurfacing;

final RawDiveParseFn _parseFn;
final RawLogFileReader _reader;
final bool _trimAtSurfacing;

/// The most records that make up one dive. A Suunto Nautic dive is a
/// profile record optionally followed by its `/Summary` record.
static const _maxRecordsPerDive = 2;

/// Parse [bytes] into dives. Throws [MissingPluginException] or a
/// `PlatformException(code: 'UNSUPPORTED')` straight through so the caller
/// can tell "the parser is unavailable" from "this file did not parse".
Future<RawLogImportOutcome> parse(Uint8List bytes) async {
final file = _reader.read(bytes);
if (file == null) {
return const RawLogImportOutcome(
dives: [],
recordsRead: 0,
divesFailed: 0,
warnings: ['This file is not a recognised Suunto Nautic / Ocean log.'],
);
}

final warnings = <String>[
if (file.leadingJunkBytes > 0)
'${file.leadingJunkBytes} byte(s) before the first record were skipped.',
];

final dives = <DownloadedDive>[];
var failed = 0;

// Greedy longest-match over the record list: at each position try the
// widest group first (profile + Summary), fall back to the profile
// alone, and only give up on a record when neither parses.
var i = 0;
while (i < file.records.length) {
pigeon.ParsedDive? parsed;
var consumed = 0;

final maxGroup = (file.records.length - i)
.clamp(1, _maxRecordsPerDive)
.toInt();
for (var group = maxGroup; group >= 1; group--) {
final blob = _join(file.records.sublist(i, i + group));
final candidate = await _tryParse(file, blob);
if (candidate != null && RawProfileSanityCheck.accepts(candidate)) {
parsed = candidate;
consumed = group;
break;
}
}

if (parsed == null) {
failed++;
i += 1;
continue;
}

dives.add(
parsedDiveToDownloaded(parsed, trimAtSurfacing: _trimAtSurfacing),
);
i += consumed;
}

if (dives.isEmpty && failed > 0) {
warnings.add(
'The file was recognised but none of its $failed record group(s) '
'parsed as a dive.',
);
}

dives.sort((a, b) => a.startTime.compareTo(b.startTime));

return RawLogImportOutcome(
dives: dives,
recordsRead: file.records.length,
divesFailed: failed,
warnings: warnings,
);
}

Future<pigeon.ParsedDive?> _tryParse(RawLogFile file, Uint8List blob) async {
try {
return await _parseFn(file.vendor, file.product, file.model, blob);
} on MissingPluginException {
rethrow;
} on PlatformException catch (e) {
if (e.code == 'UNSUPPORTED' || e.code == 'channel-error') rethrow;
return null;
}
}

static Uint8List _join(List<Uint8List> parts) {
if (parts.length == 1) return parts.first;
final total = parts.fold<int>(0, (sum, p) => sum + p.length);
final out = Uint8List(total);
var offset = 0;
for (final p in parts) {
out.setRange(offset, offset + p.length, p);
offset += p.length;
}
return out;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import 'package:submersion/features/dive_log/presentation/providers/dive_compute
import 'package:submersion/features/dive_log/presentation/providers/dive_providers.dart';
import 'package:submersion/features/divers/presentation/providers/diver_providers.dart';
import 'package:submersion/features/import_wizard/data/adapters/dive_computer_adapter.dart';
import 'package:submersion/features/import_wizard/data/services/import_provider_invalidator.dart';
import 'package:submersion/features/import_wizard/data/adapters/universal_adapter.dart';
import 'package:submersion/features/import_wizard/data/services/import_provider_invalidator.dart';
import 'package:submersion/features/import_wizard/domain/adapters/import_source_adapter.dart';
import 'package:submersion/features/import_wizard/domain/models/import_bundle.dart';
import 'package:submersion/features/import_wizard/domain/models/import_step_failure.dart';
Expand Down Expand Up @@ -345,6 +345,7 @@ class _UnifiedImportWizardBodyState
// resolution) may have created a new record even when all dives were
// skipped.
if (widget.adapter.sourceType == ImportSourceType.diveComputer ||
widget.adapter.sourceType == ImportSourceType.universal ||
widget.adapter.sourceType == ImportSourceType.suuntoCloud ||
widget.adapter.sourceType == ImportSourceType.garminCloud) {
ref.invalidate(allDiveComputersProvider);
Expand Down
9 changes: 8 additions & 1 deletion lib/features/universal_import/data/models/import_enums.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ enum ImportFormat {
danDl7,
ratioXml,
sqlite,

/// A raw Suunto "Vaasa" generation (Nautic / Ocean) dive-log file. The
/// universal pipeline can't parse it; detecting it lets the wizard hand
/// off to the dive-computer file import instead of dead-ending.
suuntoNauticRaw,
unknown;

String get displayName => switch (this) {
Expand All @@ -31,6 +36,7 @@ enum ImportFormat {
danDl7 => 'DAN DL7',
ratioXml => 'Ratio XML',
sqlite => 'SQLite Database',
suuntoNauticRaw => 'Suunto Nautic / Ocean',
unknown => 'Unknown',
};

Expand All @@ -44,7 +50,8 @@ enum ImportFormat {
macdiveXml ||
macdiveSqlite ||
danDl7 ||
ratioXml => true,
ratioXml ||
suuntoNauticRaw => true,
_ => false,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:submersion/features/universal_import/data/parsers/macdive_sqlite
import 'package:submersion/features/universal_import/data/parsers/macdive_xml_parser.dart';
import 'package:submersion/features/universal_import/data/parsers/placeholder_parser.dart';
import 'package:submersion/features/universal_import/data/parsers/ratio_xml_parser.dart';
import 'package:submersion/features/universal_import/data/parsers/raw_dive_computer_parser.dart';
import 'package:submersion/features/universal_import/data/parsers/shearwater_cloud_parser.dart';
import 'package:submersion/features/universal_import/data/parsers/subsurface_xml_parser.dart';
import 'package:submersion/features/universal_import/data/parsers/uddf_import_parser.dart';
Expand All @@ -22,6 +23,7 @@ ImportParser parserForFormat(ImportFormat format) {
ImportFormat.fit => const FitImportParser(),
ImportFormat.shearwaterDb => ShearwaterCloudParser(),
ImportFormat.ratioXml => const RatioXmlParser(),
ImportFormat.suuntoNauticRaw => const RawDiveComputerParser(),
_ => const PlaceholderParser(),
};
}
Loading