diff --git a/lib/features/dive_computer/data/services/parsed_tank_resolver.dart b/lib/features/dive_computer/data/services/parsed_tank_resolver.dart index aea033bee4..7865994948 100644 --- a/lib/features/dive_computer/data/services/parsed_tank_resolver.dart +++ b/lib/features/dive_computer/data/services/parsed_tank_resolver.dart @@ -111,6 +111,7 @@ _ResolvedCylinders _resolveCylinders( return const _ResolvedCylinders([], {}); } final tanks = []; + final roles = _inferSensorlessRoles(gasMixes, parsed.diveMode); for (var i = 0; i < gasMixes.length; i++) { final g = gasMixes[i]; gasIndexToTankIndex[i] = g.index; @@ -119,7 +120,7 @@ _ResolvedCylinders _resolveCylinders( index: g.index, o2Percent: g.o2Percent, hePercent: g.hePercent, - role: _inferRole(null, g.o2Percent, g.hePercent), + role: roles[i], ), ); } @@ -205,6 +206,92 @@ String _inferRole(int? usage, double o2Percent, double hePercent) { return TankRole.backGas.name; } +/// Role for each of [gasMixes], in order, on a sensorless (tankless) dive. +/// +/// A gas whose usage the computer reported directly on the gas mix itself +/// (`dc_gasmix_t.usage`, independent of any tank/transmitter record) is +/// authoritative device data, regardless of dive mode: oxygen maps to +/// [TankRole.oxygenSupply], diluent to [TankRole.diluent]. Sidemount maps to +/// [TankRole.backGas] rather than [TankRole.sidemountLeft]/[TankRole.sidemountRight] +/// -- the flag only says the gas is on a sidemount cylinder, not which side, +/// so it cannot pick between the two. +/// +/// For a dive recognized as CCR, the gases left with no reported usage are +/// the open-circuit bailout candidates and are ranked against each other +/// instead of scored in isolation: +/// 1. Bottom gas: the lowest O2 percentage among them becomes +/// [TankRole.bailout]; a tie is broken by the higher helium percentage, +/// and a further tie gives Bailout to every still-tied gas. A gas that +/// only loses the helium tie-break gets no automatic Bailout role and +/// falls through to the next rule. +/// 2. Deco: every still-unassigned gas at or above the same 41% O2 +/// threshold [_inferRole] uses for open circuit becomes [TankRole.deco]. +/// 3. Stage: everything still unassigned becomes [TankRole.stage]. +/// +/// On any other recognized dive mode, a gas with no reported usage keeps +/// [_inferRole]'s original single-threshold heuristic, unaffected by this. +List _inferSensorlessRoles( + List gasMixes, + String? diveMode, +) { + final roles = List.filled(gasMixes.length, null); + final unranked = []; + for (var i = 0; i < gasMixes.length; i++) { + final g = gasMixes[i]; + switch (g.usage) { + case 1: // DC_USAGE_OXYGEN + roles[i] = TankRole.oxygenSupply.name; + case 2: // DC_USAGE_DILUENT + roles[i] = TankRole.diluent.name; + case 3: // DC_USAGE_SIDEMOUNT + roles[i] = TankRole.backGas.name; + default: + unranked.add(i); + } + } + + if (diveMode != 'ccr') { + for (final i in unranked) { + final g = gasMixes[i]; + roles[i] = _inferRole(null, g.o2Percent, g.hePercent); + } + return [for (final role in roles) role!]; + } + + if (unranked.isNotEmpty) { + final lowestO2 = unranked + .map((i) => gasMixes[i].o2Percent) + .reduce((a, b) => a < b ? a : b); + final atLowestO2 = unranked.where( + (i) => _nearlyEqualPercent(gasMixes[i].o2Percent, lowestO2), + ); + final highestHeAtLowestO2 = atLowestO2 + .map((i) => gasMixes[i].hePercent) + .reduce((a, b) => a > b ? a : b); + for (final i in atLowestO2) { + if (_nearlyEqualPercent(gasMixes[i].hePercent, highestHeAtLowestO2)) { + roles[i] = TankRole.bailout.name; + } + } + for (final i in unranked) { + if (roles[i] != null) continue; + roles[i] = gasMixes[i].o2Percent >= 41.0 + ? TankRole.deco.name + : TankRole.stage.name; + } + } + + return [for (final role in roles) role!]; +} + +/// Whether two gas percentages are the same value within floating-point +/// noise. Each of the four platform converters independently computes +/// `fraction * 100.0` from the native `dc_gasmix_t`, so two mixes the diver +/// set to the same nominal percentage can differ by a few ULPs; an exact +/// `==` would then miss a real tie in [_inferSensorlessRoles]'s bailout +/// ranking. +bool _nearlyEqualPercent(double a, double b) => (a - b).abs() < 1e-6; + /// The gas-mix index (position in [gasMixes]) for [tank], preferring the gas /// actually breathed on it. Returns null only when there are no gas mixes. int? _resolveTankGasIndex( diff --git a/packages/libdivecomputer_plugin/android/src/main/cpp/libdc_jni.cpp b/packages/libdivecomputer_plugin/android/src/main/cpp/libdc_jni.cpp index f01b8c21f7..70871ea3fe 100644 --- a/packages/libdivecomputer_plugin/android/src/main/cpp/libdc_jni.cpp +++ b/packages/libdivecomputer_plugin/android/src/main/cpp/libdc_jni.cpp @@ -1086,9 +1086,10 @@ Java_com_submersion_libdivecomputer_LibdcWrapper_nativeGetDiveGasmix( if (index < 0 || static_cast(index) >= dive->gasmix_count) return nullptr; const libdc_gasmix_t *gm = &dive->gasmixes[index]; - jdouble values[2] = { gm->oxygen, gm->helium }; - jdoubleArray result = env->NewDoubleArray(2); - env->SetDoubleArrayRegion(result, 0, 2, values); + // Return [oxygen, helium, usage]. Positional: the Kotlin readers index it. + jdouble values[3] = { gm->oxygen, gm->helium, static_cast(gm->usage) }; + jdoubleArray result = env->NewDoubleArray(3); + env->SetDoubleArrayRegion(result, 0, 3, values); return result; } 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 11a226b22a..c9b2715e54 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 @@ -255,7 +255,14 @@ data class ProfileSample ( data class GasMix ( val index: Long, val o2Percent: Double, - val hePercent: Double + val hePercent: Double, + /** + * Gas usage from libdivecomputer's `dc_usage_t` (1=oxygen, 2=diluent, + * 3=sidemount); null when the computer reported no usage (DC_USAGE_NONE). + * Set on the gas mix itself, so it is available even when the mix has no + * tank/transmitter record. + */ + val usage: Long? = null ) { companion object { @@ -263,7 +270,8 @@ data class GasMix ( val index = pigeonVar_list[0] as Long val o2Percent = pigeonVar_list[1] as Double val hePercent = pigeonVar_list[2] as Double - return GasMix(index, o2Percent, hePercent) + val usage = pigeonVar_list[3] as Long? + return GasMix(index, o2Percent, hePercent, usage) } } fun toList(): List { @@ -271,6 +279,7 @@ data class GasMix ( index, o2Percent, hePercent, + usage, ) } } 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 5614f440d3..0ef0cbef56 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 @@ -688,7 +688,8 @@ class DiveComputerHostApiImpl( GasMix( index = i.toLong(), o2Percent = gm[0] * 100.0, - hePercent = gm[1] * 100.0 + hePercent = gm[1] * 100.0, + usage = gm.getOrNull(2)?.toLong()?.takeIf { it != 0L } ) } diff --git a/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/SerialDownloadRunner.kt b/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/SerialDownloadRunner.kt index f5fc0a41e7..c0753b7324 100644 --- a/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/SerialDownloadRunner.kt +++ b/packages/libdivecomputer_plugin/android/src/main/kotlin/com/submersion/libdivecomputer/SerialDownloadRunner.kt @@ -184,7 +184,8 @@ class SerialDownloadRunner(private val context: Context) { GasMix( index = i.toLong(), o2Percent = gm[0] * 100.0, - hePercent = gm[1] * 100.0 + hePercent = gm[1] * 100.0, + usage = gm.getOrNull(2)?.toLong()?.takeIf { it != 0L } ) } diff --git a/packages/libdivecomputer_plugin/darwin/Sources/LibDCDarwin/DiveComputerHostApiImpl.swift b/packages/libdivecomputer_plugin/darwin/Sources/LibDCDarwin/DiveComputerHostApiImpl.swift index e0a4a89097..981a6deef8 100644 --- a/packages/libdivecomputer_plugin/darwin/Sources/LibDCDarwin/DiveComputerHostApiImpl.swift +++ b/packages/libdivecomputer_plugin/darwin/Sources/LibDCDarwin/DiveComputerHostApiImpl.swift @@ -867,7 +867,8 @@ class DiveComputerHostApiImpl: DiveComputerHostApi { gasMixes.append(GasMix( index: Int64(i), o2Percent: gm.oxygen * 100.0, - hePercent: gm.helium * 100.0 + hePercent: gm.helium * 100.0, + usage: gm.usage == 0 ? nil : Int64(gm.usage) )) } } diff --git a/packages/libdivecomputer_plugin/ios/Classes/DiveComputerApi.g.swift b/packages/libdivecomputer_plugin/ios/Classes/DiveComputerApi.g.swift index a7e271b02c..89935f90dc 100644 --- a/packages/libdivecomputer_plugin/ios/Classes/DiveComputerApi.g.swift +++ b/packages/libdivecomputer_plugin/ios/Classes/DiveComputerApi.g.swift @@ -301,6 +301,11 @@ struct GasMix { var index: Int64 var o2Percent: Double var hePercent: Double + /// Gas usage from libdivecomputer's `dc_usage_t` (1=oxygen, 2=diluent, + /// 3=sidemount); null when the computer reported no usage (DC_USAGE_NONE). + /// Set on the gas mix itself, so it is available even when the mix has no + /// tank/transmitter record. + var usage: Int64? = nil // swift-format-ignore: AlwaysUseLowerCamelCase @@ -308,11 +313,13 @@ struct GasMix { let index = pigeonVar_list[0] as! Int64 let o2Percent = pigeonVar_list[1] as! Double let hePercent = pigeonVar_list[2] as! Double + let usage: Int64? = nilOrValue(pigeonVar_list[3]) return GasMix( index: index, o2Percent: o2Percent, - hePercent: hePercent + hePercent: hePercent, + usage: usage ) } func toList() -> [Any?] { @@ -320,6 +327,7 @@ struct GasMix { index, o2Percent, hePercent, + usage, ] } } 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 df42286cee..6e18b1aeb1 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 @@ -284,6 +284,7 @@ class GasMix { required this.index, required this.o2Percent, required this.hePercent, + this.usage, }); int index; @@ -292,8 +293,14 @@ class GasMix { double hePercent; + /// Gas usage from libdivecomputer's `dc_usage_t` (1=oxygen, 2=diluent, + /// 3=sidemount); null when the computer reported no usage (DC_USAGE_NONE). + /// Set on the gas mix itself, so it is available even when the mix has no + /// tank/transmitter record. + int? usage; + Object encode() { - return [index, o2Percent, hePercent]; + return [index, o2Percent, hePercent, usage]; } static GasMix decode(Object result) { @@ -302,6 +309,7 @@ class GasMix { index: result[0]! as int, o2Percent: result[1]! as double, hePercent: result[2]! as double, + usage: result[3] as int?, ); } } diff --git a/packages/libdivecomputer_plugin/linux/dive_computer_api.g.cc b/packages/libdivecomputer_plugin/linux/dive_computer_api.g.cc index 4dd86db43a..fa2e8e8d6f 100644 --- a/packages/libdivecomputer_plugin/linux/dive_computer_api.g.cc +++ b/packages/libdivecomputer_plugin/linux/dive_computer_api.g.cc @@ -834,11 +834,14 @@ struct _LibdivecomputerPluginGasMix { int64_t index; double o2_percent; double he_percent; + int64_t* usage; }; G_DEFINE_TYPE(LibdivecomputerPluginGasMix, libdivecomputer_plugin_gas_mix, G_TYPE_OBJECT) static void libdivecomputer_plugin_gas_mix_dispose(GObject* object) { + LibdivecomputerPluginGasMix* self = LIBDIVECOMPUTER_PLUGIN_GAS_MIX(object); + g_clear_pointer(&self->usage, g_free); G_OBJECT_CLASS(libdivecomputer_plugin_gas_mix_parent_class)->dispose(object); } @@ -849,11 +852,18 @@ static void libdivecomputer_plugin_gas_mix_class_init(LibdivecomputerPluginGasMi G_OBJECT_CLASS(klass)->dispose = libdivecomputer_plugin_gas_mix_dispose; } -LibdivecomputerPluginGasMix* libdivecomputer_plugin_gas_mix_new(int64_t index, double o2_percent, double he_percent) { +LibdivecomputerPluginGasMix* libdivecomputer_plugin_gas_mix_new(int64_t index, double o2_percent, double he_percent, int64_t* usage) { LibdivecomputerPluginGasMix* self = LIBDIVECOMPUTER_PLUGIN_GAS_MIX(g_object_new(libdivecomputer_plugin_gas_mix_get_type(), nullptr)); self->index = index; self->o2_percent = o2_percent; self->he_percent = he_percent; + if (usage != nullptr) { + self->usage = static_cast(malloc(sizeof(int64_t))); + *self->usage = *usage; + } + else { + self->usage = nullptr; + } return self; } @@ -872,11 +882,17 @@ double libdivecomputer_plugin_gas_mix_get_he_percent(LibdivecomputerPluginGasMix return self->he_percent; } +int64_t* libdivecomputer_plugin_gas_mix_get_usage(LibdivecomputerPluginGasMix* self) { + g_return_val_if_fail(LIBDIVECOMPUTER_PLUGIN_IS_GAS_MIX(self), nullptr); + return self->usage; +} + static FlValue* libdivecomputer_plugin_gas_mix_to_list(LibdivecomputerPluginGasMix* self) { FlValue* values = fl_value_new_list(); fl_value_append_take(values, fl_value_new_int(self->index)); fl_value_append_take(values, fl_value_new_float(self->o2_percent)); fl_value_append_take(values, fl_value_new_float(self->he_percent)); + fl_value_append_take(values, self->usage != nullptr ? fl_value_new_int(*self->usage) : fl_value_new_null()); return values; } @@ -887,7 +903,14 @@ static LibdivecomputerPluginGasMix* libdivecomputer_plugin_gas_mix_new_from_list double o2_percent = fl_value_get_float(value1); FlValue* value2 = fl_value_get_list_value(values, 2); double he_percent = fl_value_get_float(value2); - return libdivecomputer_plugin_gas_mix_new(index, o2_percent, he_percent); + FlValue* value3 = fl_value_get_list_value(values, 3); + int64_t* usage = nullptr; + int64_t usage_value; + if (fl_value_get_type(value3) != FL_VALUE_TYPE_NULL) { + usage_value = fl_value_get_int(value3); + usage = &usage_value; + } + return libdivecomputer_plugin_gas_mix_new(index, o2_percent, he_percent, usage); } struct _LibdivecomputerPluginTankInfo { diff --git a/packages/libdivecomputer_plugin/linux/dive_computer_api.g.h b/packages/libdivecomputer_plugin/linux/dive_computer_api.g.h index c01c4d3855..43ca4e32b3 100644 --- a/packages/libdivecomputer_plugin/linux/dive_computer_api.g.h +++ b/packages/libdivecomputer_plugin/linux/dive_computer_api.g.h @@ -524,12 +524,13 @@ G_DECLARE_FINAL_TYPE(LibdivecomputerPluginGasMix, libdivecomputer_plugin_gas_mix * index: field in this object. * o2_percent: field in this object. * he_percent: field in this object. + * usage: field in this object. * * Creates a new #GasMix object. * * Returns: a new #LibdivecomputerPluginGasMix */ -LibdivecomputerPluginGasMix* libdivecomputer_plugin_gas_mix_new(int64_t index, double o2_percent, double he_percent); +LibdivecomputerPluginGasMix* libdivecomputer_plugin_gas_mix_new(int64_t index, double o2_percent, double he_percent, int64_t* usage); /** * libdivecomputer_plugin_gas_mix_get_index @@ -561,6 +562,19 @@ double libdivecomputer_plugin_gas_mix_get_o2_percent(LibdivecomputerPluginGasMix */ double libdivecomputer_plugin_gas_mix_get_he_percent(LibdivecomputerPluginGasMix* object); +/** + * libdivecomputer_plugin_gas_mix_get_usage + * @object: a #LibdivecomputerPluginGasMix. + * + * Gas usage from libdivecomputer's `dc_usage_t` (1=oxygen, 2=diluent, + * 3=sidemount); null when the computer reported no usage (DC_USAGE_NONE). + * Set on the gas mix itself, so it is available even when the mix has no + * tank/transmitter record. + * + * Returns: the field value. + */ +int64_t* libdivecomputer_plugin_gas_mix_get_usage(LibdivecomputerPluginGasMix* object); + /** * LibdivecomputerPluginTankInfo: * diff --git a/packages/libdivecomputer_plugin/linux/dive_converter.c b/packages/libdivecomputer_plugin/linux/dive_converter.c index 1c9343facb..c144f6c074 100644 --- a/packages/libdivecomputer_plugin/linux/dive_converter.c +++ b/packages/libdivecomputer_plugin/linux/dive_converter.c @@ -165,11 +165,15 @@ LibdivecomputerPluginParsedDive* convert_parsed_dive( // Convert gas mixes. FlValue* gas_mixes = fl_value_new_list(); for (unsigned int i = 0; i < dive->gasmix_count; i++) { + int64_t gm_usage_val = (int64_t)dive->gasmixes[i].usage; + int64_t* gm_usage = (dive->gasmixes[i].usage == 0) ? NULL : &gm_usage_val; + LibdivecomputerPluginGasMix* mix = libdivecomputer_plugin_gas_mix_new( (int64_t)i, dive->gasmixes[i].oxygen * 100.0, - dive->gasmixes[i].helium * 100.0); + dive->gasmixes[i].helium * 100.0, + gm_usage); fl_value_append_take( gas_mixes, fl_value_new_custom_object(133, G_OBJECT(mix))); diff --git a/packages/libdivecomputer_plugin/macos/Classes/libdc_download.c b/packages/libdivecomputer_plugin/macos/Classes/libdc_download.c index 828d78294f..14a6548f11 100644 --- a/packages/libdivecomputer_plugin/macos/Classes/libdc_download.c +++ b/packages/libdivecomputer_plugin/macos/Classes/libdc_download.c @@ -590,6 +590,7 @@ static int extract_dive_fields(dc_parser_t *parser, libdc_parsed_dive_t *dive) { if (dc_parser_get_field(parser, DC_FIELD_GASMIX, i, &gm) == DC_STATUS_SUCCESS) { dive->gasmixes[i].oxygen = gm.oxygen; dive->gasmixes[i].helium = gm.helium; + dive->gasmixes[i].usage = gm.usage; } } dive->gasmix_count = gasmix_count; diff --git a/packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.h b/packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.h index 2542962450..d00db4d9f0 100644 --- a/packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.h +++ b/packages/libdivecomputer_plugin/macos/Classes/libdc_wrapper.h @@ -215,6 +215,7 @@ typedef struct { typedef struct { double oxygen; // fraction 0.0-1.0 double helium; // fraction 0.0-1.0 + unsigned int usage; // dc_usage_t (0=none, 1=oxygen, 2=diluent, 3=sidemount) } libdc_gasmix_t; typedef struct { diff --git a/packages/libdivecomputer_plugin/pigeons/dive_computer_api.dart b/packages/libdivecomputer_plugin/pigeons/dive_computer_api.dart index 7014940348..511dda390f 100644 --- a/packages/libdivecomputer_plugin/pigeons/dive_computer_api.dart +++ b/packages/libdivecomputer_plugin/pigeons/dive_computer_api.dart @@ -140,10 +140,17 @@ class GasMix { required this.index, required this.o2Percent, required this.hePercent, + this.usage, }); final int index; final double o2Percent; final double hePercent; + + /// Gas usage from libdivecomputer's `dc_usage_t` (1=oxygen, 2=diluent, + /// 3=sidemount); null when the computer reported no usage (DC_USAGE_NONE). + /// Set on the gas mix itself, so it is available even when the mix has no + /// tank/transmitter record. + final int? usage; } class TankInfo { diff --git a/packages/libdivecomputer_plugin/windows/dive_computer_api.g.cc b/packages/libdivecomputer_plugin/windows/dive_computer_api.g.cc index cf4d9f619f..c5555f9e53 100644 --- a/packages/libdivecomputer_plugin/windows/dive_computer_api.g.cc +++ b/packages/libdivecomputer_plugin/windows/dive_computer_api.g.cc @@ -804,6 +804,16 @@ GasMix::GasMix( o2_percent_(o2_percent), he_percent_(he_percent) {} +GasMix::GasMix( + int64_t index, + double o2_percent, + double he_percent, + const int64_t* usage) + : index_(index), + o2_percent_(o2_percent), + he_percent_(he_percent), + usage_(usage ? std::optional(*usage) : std::nullopt) {} + int64_t GasMix::index() const { return index_; } @@ -831,12 +841,26 @@ void GasMix::set_he_percent(double value_arg) { } +const int64_t* GasMix::usage() const { + return usage_ ? &(*usage_) : nullptr; +} + +void GasMix::set_usage(const int64_t* value_arg) { + usage_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void GasMix::set_usage(int64_t value_arg) { + usage_ = value_arg; +} + + EncodableList GasMix::ToEncodableList() const { EncodableList list; - list.reserve(3); + list.reserve(4); list.push_back(EncodableValue(index_)); list.push_back(EncodableValue(o2_percent_)); list.push_back(EncodableValue(he_percent_)); + list.push_back(usage_ ? EncodableValue(*usage_) : EncodableValue()); return list; } @@ -845,6 +869,10 @@ GasMix GasMix::FromEncodableList(const EncodableList& list) { std::get(list[0]), std::get(list[1]), std::get(list[2])); + auto& encodable_usage = list[3]; + if (!encodable_usage.IsNull()) { + decoded.set_usage(std::get(encodable_usage)); + } return decoded; } diff --git a/packages/libdivecomputer_plugin/windows/dive_computer_api.g.h b/packages/libdivecomputer_plugin/windows/dive_computer_api.g.h index 5ff8900ad5..643d7ecd59 100644 --- a/packages/libdivecomputer_plugin/windows/dive_computer_api.g.h +++ b/packages/libdivecomputer_plugin/windows/dive_computer_api.g.h @@ -372,12 +372,19 @@ class ProfileSample { // Generated class from Pigeon that represents data sent in messages. class GasMix { public: - // Constructs an object setting all fields. + // Constructs an object setting all non-nullable fields. explicit GasMix( int64_t index, double o2_percent, double he_percent); + // Constructs an object setting all fields. + explicit GasMix( + int64_t index, + double o2_percent, + double he_percent, + const int64_t* usage); + int64_t index() const; void set_index(int64_t value_arg); @@ -387,6 +394,14 @@ class GasMix { double he_percent() const; void set_he_percent(double value_arg); + // Gas usage from libdivecomputer's `dc_usage_t` (1=oxygen, 2=diluent, + // 3=sidemount); null when the computer reported no usage (DC_USAGE_NONE). + // Set on the gas mix itself, so it is available even when the mix has no + // tank/transmitter record. + const int64_t* usage() const; + void set_usage(const int64_t* value_arg); + void set_usage(int64_t value_arg); + private: static GasMix FromEncodableList(const flutter::EncodableList& list); @@ -397,6 +412,7 @@ class GasMix { int64_t index_; double o2_percent_; double he_percent_; + std::optional usage_; }; diff --git a/packages/libdivecomputer_plugin/windows/dive_converter.cc b/packages/libdivecomputer_plugin/windows/dive_converter.cc index 75df0af249..27d92bb7e0 100644 --- a/packages/libdivecomputer_plugin/windows/dive_converter.cc +++ b/packages/libdivecomputer_plugin/windows/dive_converter.cc @@ -176,10 +176,17 @@ ParsedDive ConvertParsedDive(const libdc_parsed_dive_t& dive) { flutter::EncodableList gas_mixes; gas_mixes.reserve(dive.gasmix_count); for (unsigned int i = 0; i < dive.gasmix_count; i++) { + std::optional opt_gm_usage = + (dive.gasmixes[i].usage == 0) + ? std::nullopt + : std::optional( + static_cast(dive.gasmixes[i].usage)); + gas_mixes.push_back(flutter::CustomEncodableValue(GasMix( static_cast(i), dive.gasmixes[i].oxygen * 100.0, - dive.gasmixes[i].helium * 100.0))); + dive.gasmixes[i].helium * 100.0, + opt_gm_usage ? &*opt_gm_usage : nullptr))); } // Convert tanks. diff --git a/test/features/dive_computer/data/services/parsed_tank_resolver_test.dart b/test/features/dive_computer/data/services/parsed_tank_resolver_test.dart index 18d745586f..4f3da9f375 100644 --- a/test/features/dive_computer/data/services/parsed_tank_resolver_test.dart +++ b/test/features/dive_computer/data/services/parsed_tank_resolver_test.dart @@ -416,6 +416,108 @@ void main() { expect(tanks.firstWhere((t) => t.o2Percent == 32.0).role, 'backGas'); expect(tanks.firstWhere((t) => t.o2Percent == 99.0).role, 'deco'); }); + + test('sensorless CCR: reported gas usage maps oxygen/diluent directly ' + 'and sidemount to back gas, regardless of O2%', () { + final parsed = makeParsedDive( + diveMode: 'ccr', + gasMixes: [ + pigeon.GasMix(index: 0, o2Percent: 100.0, hePercent: 0.0, usage: 1), + pigeon.GasMix(index: 1, o2Percent: 18.0, hePercent: 45.0, usage: 2), + pigeon.GasMix(index: 2, o2Percent: 21.0, hePercent: 0.0, usage: 3), + ], + ); + final tanks = resolveParsedTanks(parsed); + expect( + tanks.firstWhere((t) => t.o2Percent == 100.0).role, + 'oxygenSupply', + ); + expect(tanks.firstWhere((t) => t.o2Percent == 18.0).role, 'diluent'); + expect(tanks.firstWhere((t) => t.o2Percent == 21.0).role, 'backGas'); + }); + + test( + 'sensorless CCR: bailout gases with no reported usage are ranked ' + 'against each other (lowest O2 -> bailout, >=41% -> deco, rest -> stage)', + () { + final parsed = makeParsedDive( + diveMode: 'ccr', + gasMixes: [ + pigeon.GasMix(index: 0, o2Percent: 18.0, hePercent: 45.0), + pigeon.GasMix(index: 1, o2Percent: 50.0, hePercent: 0.0), + pigeon.GasMix(index: 2, o2Percent: 32.0, hePercent: 0.0), + ], + ); + final tanks = resolveParsedTanks(parsed); + expect(tanks.firstWhere((t) => t.o2Percent == 18.0).role, 'bailout'); + expect(tanks.firstWhere((t) => t.o2Percent == 50.0).role, 'deco'); + expect(tanks.firstWhere((t) => t.o2Percent == 32.0).role, 'stage'); + }, + ); + + test('sensorless CCR bailout tie: same lowest O2, higher helium wins ' + 'bailout; the loser falls through to deco/stage instead', () { + final parsed = makeParsedDive( + diveMode: 'ccr', + gasMixes: [ + pigeon.GasMix(index: 0, o2Percent: 18.0, hePercent: 45.0), + pigeon.GasMix(index: 1, o2Percent: 18.0, hePercent: 50.0), + pigeon.GasMix(index: 2, o2Percent: 32.0, hePercent: 0.0), + ], + ); + final tanks = resolveParsedTanks(parsed); + expect(tanks.firstWhere((t) => t.hePercent == 45.0).role, 'stage'); + expect(tanks.firstWhere((t) => t.hePercent == 50.0).role, 'bailout'); + expect(tanks.firstWhere((t) => t.o2Percent == 32.0).role, 'stage'); + }); + + test('sensorless CCR bailout tie: same lowest O2 and helium -> both ' + 'gases get the bailout role', () { + final parsed = makeParsedDive( + diveMode: 'ccr', + gasMixes: [ + pigeon.GasMix(index: 0, o2Percent: 18.0, hePercent: 45.0), + pigeon.GasMix(index: 1, o2Percent: 18.0, hePercent: 45.0), + ], + ); + final tanks = resolveParsedTanks(parsed); + expect(tanks.every((t) => t.role == 'bailout'), isTrue); + }); + + test('sensorless CCR bailout tie: floating-point noise from the native ' + 'fraction * 100.0 conversion does not defeat the tie detection', () { + // Each platform converter computes o2/he percent independently from + // the native fraction, so two mixes the diver set to the same + // nominal percentage can differ by a few ULPs rather than being + // bit-identical. + final parsed = makeParsedDive( + diveMode: 'ccr', + gasMixes: [ + pigeon.GasMix(index: 0, o2Percent: 18.0, hePercent: 45.0), + pigeon.GasMix( + index: 1, + o2Percent: 18.0 + 4e-14, + hePercent: 45.0 - 4e-14, + ), + ], + ); + final tanks = resolveParsedTanks(parsed); + expect(tanks.every((t) => t.role == 'bailout'), isTrue); + }); + + test('sensorless SCR keeps the original single-threshold heuristic, ' + 'unaffected by the CCR bailout ranking', () { + final parsed = makeParsedDive( + diveMode: 'scr', + gasMixes: [ + pigeon.GasMix(index: 0, o2Percent: 18.0, hePercent: 45.0), + pigeon.GasMix(index: 1, o2Percent: 50.0, hePercent: 0.0), + ], + ); + final tanks = resolveParsedTanks(parsed); + expect(tanks.firstWhere((t) => t.o2Percent == 18.0).role, 'backGas'); + expect(tanks.firstWhere((t) => t.o2Percent == 50.0).role, 'deco'); + }); }); });