Skip to content

Commit 9e519a8

Browse files
committed
feat(macdive): decode ZRAWDATA profiles via libdivecomputer_plugin
MacDiveDiveMapper now decodes ZRAWDATA via parseRawDiveData (same API ShearwaterDiveMapper uses for Shearwater Cloud). toPayload becomes async and threads warnings through. _vendorProductFromZComputer maps ZCOMPUTER strings to libdivecomputer (vendor, product) pairs. Sample projection matches ShearwaterDiveMapper.mergeWithParsedDive exactly (same keys, same conditional emission). Dives without ZRAWDATA or with unmapped computers emit profile:[] silently; decode failures emit profile:[] + ImportWarning. Closes the ZRAWDATA part of the MacDive SQLite profile gap from submersion-app#256.
1 parent 4d96df7 commit 9e519a8

3 files changed

Lines changed: 316 additions & 26 deletions

File tree

lib/features/universal_import/data/parsers/macdive_sqlite_parser.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ class MacDiveSqliteParser implements ImportParser {
5353
],
5454
);
5555
}
56-
return MacDiveDiveMapper.toPayload(logbook);
56+
return await MacDiveDiveMapper.toPayload(logbook);
5757
} catch (e) {
5858
return ImportPayload(
5959
entities: const {},

lib/features/universal_import/data/services/macdive_dive_mapper.dart

Lines changed: 145 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,37 @@
1+
import 'dart:typed_data';
2+
3+
import 'package:libdivecomputer_plugin/libdivecomputer_plugin.dart' as pigeon;
4+
15
import 'package:submersion/features/universal_import/data/models/import_enums.dart';
26
import 'package:submersion/features/universal_import/data/models/import_payload.dart';
7+
import 'package:submersion/features/universal_import/data/models/import_warning.dart';
38
import 'package:submersion/features/universal_import/data/services/macdive_raw_types.dart';
49
import 'package:submersion/features/universal_import/data/services/macdive_unit_converter.dart';
510
import 'package:submersion/features/universal_import/data/services/macdive_value_mapper.dart';
611
import 'package:submersion/features/universal_import/data/services/macdive_xml_models.dart'
712
show MacDiveUnitSystem;
813

14+
/// Signature for the [pigeon.DiveComputerHostApi.parseRawDiveData] call so
15+
/// tests can inject a fake without spawning the platform channel.
16+
typedef ParseRawDiveDataFn =
17+
Future<pigeon.ParsedDive> Function(
18+
String vendor,
19+
String product,
20+
int model,
21+
Uint8List data,
22+
);
23+
924
/// Maps a [MacDiveRawLogbook] (raw SQLite rows read by [MacDiveDbReader])
1025
/// into a unified [ImportPayload] the rest of the import pipeline consumes
1126
/// without knowing the source was SQLite. Key conventions mirror the M2
1227
/// `MacDiveXmlParser` so the downstream `UddfEntityImporter` processes
1328
/// both sources through the same code path.
1429
///
15-
/// Profile samples (`ZSAMPLES`) are NOT decoded in M3 - MacDive uses a
16-
/// proprietary binary format (not bplist, not a common compression).
17-
/// `profile: []` is emitted for every dive. Users who need profile
18-
/// samples should use the UDDF import path (M1), which decodes MacDive's
19-
/// UDDF profile correctly.
30+
/// Profile samples are decoded from the `ZRAWDATA` BLOB via
31+
/// [pigeon.DiveComputerHostApi.parseRawDiveData] (the same path used by
32+
/// [ShearwaterDiveMapper] for Shearwater Cloud imports). Dives without
33+
/// `ZRAWDATA`, with an unknown computer model, or where decoding fails emit
34+
/// `profile: []`. Decode failures additionally emit an [ImportWarning].
2035
class MacDiveDiveMapper {
2136
const MacDiveDiveMapper._();
2237

@@ -26,17 +41,27 @@ class MacDiveDiveMapper {
2641
/// String enum-ish values (waterType, entryType) go through
2742
/// [MacDiveValueMapper] so unrecognised inputs are dropped rather than
2843
/// mis-stored.
29-
static ImportPayload toPayload(MacDiveRawLogbook logbook) {
44+
///
45+
/// [parseRawDiveData] can be supplied by tests to skip the real FFI call.
46+
static Future<ImportPayload> toPayload(
47+
MacDiveRawLogbook logbook, {
48+
ParseRawDiveDataFn? parseRawDiveData,
49+
}) async {
3050
final units = MacDiveUnitSystem.fromXml(logbook.unitsPreference);
3151
final converter = MacDiveUnitConverter(units);
52+
final parseFn = parseRawDiveData ?? _defaultParse;
53+
final warnings = <ImportWarning>[];
3254

3355
final siteMaps = _buildSiteMaps(logbook, converter);
3456
final buddyMaps = _buildBuddyMaps(logbook);
3557
final tagMaps = _buildTagMaps(logbook);
3658
final gearMaps = _buildGearMaps(logbook, converter);
37-
final diveMaps = [
38-
for (final d in logbook.dives) _buildDiveMap(d, logbook, converter),
39-
];
59+
final diveMaps = <Map<String, dynamic>>[];
60+
for (final d in logbook.dives) {
61+
diveMaps.add(
62+
await _buildDiveMap(d, logbook, converter, parseFn, warnings),
63+
);
64+
}
4065

4166
final entities = <ImportEntityType, List<Map<String, dynamic>>>{};
4267
if (diveMaps.isNotEmpty) entities[ImportEntityType.dives] = diveMaps;
@@ -47,7 +72,7 @@ class MacDiveDiveMapper {
4772

4873
return ImportPayload(
4974
entities: entities,
50-
warnings: const [],
75+
warnings: warnings,
5176
metadata: {
5277
'source': 'macdive_sqlite',
5378
'diveCount': logbook.dives.length,
@@ -56,6 +81,20 @@ class MacDiveDiveMapper {
5681
);
5782
}
5883

84+
// ---- default FFI implementation ----
85+
86+
static Future<pigeon.ParsedDive> _defaultParse(
87+
String vendor,
88+
String product,
89+
int model,
90+
Uint8List data,
91+
) => pigeon.DiveComputerHostApi().parseRawDiveData(
92+
vendor,
93+
product,
94+
model,
95+
data,
96+
);
97+
5998
// ---- site / buddy / tag / gear ----
6099

61100
static List<Map<String, dynamic>> _buildSiteMaps(
@@ -168,11 +207,13 @@ class MacDiveDiveMapper {
168207

169208
// ---- dive ----
170209

171-
static Map<String, dynamic> _buildDiveMap(
210+
static Future<Map<String, dynamic>> _buildDiveMap(
172211
MacDiveRawDive d,
173212
MacDiveRawLogbook logbook,
174213
MacDiveUnitConverter c,
175-
) {
214+
ParseRawDiveDataFn parseFn,
215+
List<ImportWarning> warnings,
216+
) async {
176217
final map = <String, dynamic>{};
177218

178219
if (d.uuid.isNotEmpty) map['sourceUuid'] = d.uuid;
@@ -332,10 +373,99 @@ class MacDiveDiveMapper {
332373
map['tanks'] = tanks;
333374
}
334375

335-
// Profile: always empty in M3. ZSAMPLES is MacDive's proprietary
336-
// binary sample format and is not decoded here.
337-
map['profile'] = const <Map<String, dynamic>>[];
376+
// Profile: decode ZRAWDATA via libdivecomputer_plugin when available.
377+
// Dives without ZRAWDATA or with an unmapped computer emit [] silently.
378+
// Decode failures emit [] and append an ImportWarning.
379+
map['profile'] = await _decodeProfile(d, parseFn, warnings);
338380

339381
return map;
340382
}
383+
384+
// ---- profile decoding ----
385+
386+
static Future<List<Map<String, dynamic>>> _decodeProfile(
387+
MacDiveRawDive dive,
388+
ParseRawDiveDataFn parseFn,
389+
List<ImportWarning> warnings,
390+
) async {
391+
final rawData = dive.rawDataBlob;
392+
final vendorProduct = _vendorProductFromZComputer(dive.computer);
393+
if (rawData == null || rawData.isEmpty || vendorProduct == null) {
394+
return const [];
395+
}
396+
try {
397+
final parsed = await parseFn(
398+
vendorProduct.$1,
399+
vendorProduct.$2,
400+
0,
401+
rawData,
402+
);
403+
return _projectSamples(parsed);
404+
} catch (e) {
405+
warnings.add(
406+
ImportWarning(
407+
severity: ImportWarningSeverity.warning,
408+
message: 'Profile decode failed for dive ${dive.uuid}: $e',
409+
entityType: ImportEntityType.dives,
410+
),
411+
);
412+
return const [];
413+
}
414+
}
415+
416+
/// Projects [pigeon.ParsedDive] samples into the canonical import map
417+
/// format. Mirrors [ShearwaterDiveMapper.mergeWithParsedDive] exactly for
418+
/// the sample projection block (same keys, same conditional emission).
419+
static List<Map<String, dynamic>> _projectSamples(pigeon.ParsedDive parsed) {
420+
return parsed.samples.map((s) {
421+
final sampleMap = <String, dynamic>{
422+
'timestamp': s.timeSeconds,
423+
'depth': s.depthMeters,
424+
};
425+
if (s.temperatureCelsius != null) {
426+
sampleMap['temperature'] = s.temperatureCelsius;
427+
}
428+
if (s.pressureBar != null) {
429+
sampleMap['allTankPressures'] = <Map<String, dynamic>>[
430+
{'pressure': s.pressureBar, 'tankIndex': s.tankIndex ?? 0},
431+
];
432+
}
433+
if (s.setpoint != null) sampleMap['setpoint'] = s.setpoint;
434+
if (s.ppo2 != null) sampleMap['ppO2'] = s.ppo2;
435+
if (s.heartRate != null) sampleMap['heartRate'] = s.heartRate;
436+
if (s.cns != null) sampleMap['cns'] = s.cns;
437+
if (s.rbt != null) sampleMap['rbt'] = s.rbt;
438+
if (s.tts != null) sampleMap['tts'] = s.tts;
439+
if (s.decoType != null) sampleMap['decoType'] = s.decoType;
440+
if (s.decoDepth != null && s.decoType != null && s.decoType != 0) {
441+
sampleMap['ceiling'] = s.decoDepth;
442+
}
443+
if (s.decoType == 0 && s.decoTime != null) {
444+
sampleMap['ndl'] = s.decoTime;
445+
}
446+
return sampleMap;
447+
}).toList();
448+
}
449+
450+
/// Maps MacDive's ZCOMPUTER string to the (vendor, product) pair
451+
/// libdivecomputer expects. Returns null for computers the plugin
452+
/// does not support — caller emits `profile: []` without a warning
453+
/// (not a decode failure, just an unsupported model).
454+
static (String, String)? _vendorProductFromZComputer(String? zComputer) {
455+
if (zComputer == null) return null;
456+
switch (zComputer) {
457+
case 'Shearwater Teric':
458+
return ('Shearwater', 'Teric');
459+
case 'Shearwater Tern':
460+
return ('Shearwater', 'Tern');
461+
case 'Shearwater Petrel':
462+
return ('Shearwater', 'Petrel');
463+
case 'Shearwater Perdix':
464+
return ('Shearwater', 'Perdix');
465+
case 'Shearwater Nerd':
466+
return ('Shearwater', 'Nerd');
467+
default:
468+
return null;
469+
}
470+
}
341471
}

0 commit comments

Comments
 (0)