From adf7eab8f867b6e13a9ae68156b3b916175229ad Mon Sep 17 00:00:00 2001 From: Latisha Date: Sun, 11 Oct 2026 11:52:20 +0800 Subject: [PATCH 1/3] suunto_nautic: wait for the EVA handshake reply before returning from open On a quick reconnect the watch is still sending the tail of the stream the dropped link was reading. device_open took the first frame it read (often 0 bytes) as the handshake response and returned, so the first request went out before the watch had answered the handshake, and the Ocean dropped it about two times in three. Skip frames until the Hello-Ack (0x13) arrives, or continue once the link goes quiet. --- src/suunto_nautic.c | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/src/suunto_nautic.c b/src/suunto_nautic.c index 278ee9f..2df349c 100644 --- a/src/suunto_nautic.c +++ b/src/suunto_nautic.c @@ -46,6 +46,7 @@ #define RPC_OP_DATA 0x05 #define RPC_OP_STREAM_START 0x08 // watch -> host: stream accepted (200) or refused (e.g. 423 Locked) #define RPC_OP_STREAM_END 0x09 // watch -> host: the stream is closed (answers STREAM_STOP) +#define RPC_OP_HELLO_ACK 0x13 // watch -> host: answers the EVA (Hello, 0x12) handshake // The /Data "stream" is a Whiteboard subscription: 0x10 subscribes to a // resource handle (answered by 0x08 with the current value), the watch then @@ -469,8 +470,8 @@ suunto_nautic_device_open (dc_device_t **out, dc_context_t *context, dc_iostream dc_iostream_purge (device->iostream, DC_DIRECTION_ALL); - // Best-effort EVA handshake. The response content can't be validated - // (its format isn't understood), so only the I/O round-trip is required. + // EVA handshake. The response content can't be validated (its format isn't + // understood), but its arrival can: the watch answers with a Hello-Ack. HEXDUMP (context, DC_LOGLEVEL_DEBUG, "EVA REQ", suunto_nautic_eva_handshake, EVA_HANDSHAKE_SIZE); status = dc_iostream_write (device->iostream, suunto_nautic_eva_handshake, EVA_HANDSHAKE_SIZE, NULL); @@ -479,17 +480,38 @@ suunto_nautic_device_open (dc_device_t **out, dc_context_t *context, dc_iostream goto error_close; } + // On a quick reconnect the watch is still sending the tail of the stream the + // dropped link was reading, ahead of the Hello-Ack, and a request sent before + // the Ack has arrived can go unanswered. The purge above runs too early to + // catch those frames, so skip them here until the Ack turns up. If the link + // goes quiet without one, carry on as before: the stale frames are drained. unsigned char handshake_rsp[MAX_PACKET] = {0}; size_t handshake_len = 0; - status = dc_iostream_read (device->iostream, handshake_rsp, sizeof (handshake_rsp), &handshake_len); - if (status != DC_STATUS_SUCCESS) { - ERROR (context, "Failed to receive the EVA handshake response. The device may not " - "support this protocol, or the handshake payload may need updating " - "(see suunto_nautic.h)."); - goto error_close; - } + unsigned int handshake_skips = 0; + for (;;) { + status = dc_iostream_read (device->iostream, handshake_rsp, sizeof (handshake_rsp), &handshake_len); + if (status == DC_STATUS_TIMEOUT && handshake_skips > 0) { + WARNING (context, "No EVA handshake response after %u other frames; continuing.", handshake_skips); + break; + } + if (status != DC_STATUS_SUCCESS) { + ERROR (context, "Failed to receive the EVA handshake response. The device may not " + "support this protocol, or the handshake payload may need updating " + "(see suunto_nautic.h)."); + goto error_close; + } + + HEXDUMP (context, DC_LOGLEVEL_DEBUG, "EVA RSP", handshake_rsp, handshake_len); - HEXDUMP (context, DC_LOGLEVEL_DEBUG, "EVA RSP", handshake_rsp, handshake_len); + if (handshake_len >= 2 && handshake_rsp[0] == 0xA5 && handshake_rsp[1] == RPC_OP_HELLO_ACK) + break; + if (++handshake_skips >= MAX_FOREIGN_SKIPS) { + ERROR (context, "No EVA handshake response among the frames on the link."); + status = DC_STATUS_TIMEOUT; + goto error_close; + } + WARNING (context, "Skipping a frame ahead of the EVA handshake response (" DC_PRINTF_SIZE " bytes).", handshake_len); + } *out = (dc_device_t *) device; From e70b6e3f0840e2d992c0b718eb6cde4e9886625e Mon Sep 17 00:00:00 2001 From: Latisha Date: Sun, 11 Oct 2026 12:00:05 +0800 Subject: [PATCH 2/3] suunto_nautic: read He as int16, and water type, average depth and temperatures from /Summary The gas record holds O2 and He as int16 at +1 and +3 (the watch's own /Summary schema). He was read as a byte at +2, the high byte of O2, so helium always came out 0 and trimix decoded as nitrox. Also expose the water type at 0x3E as DC_FIELD_SALINITY, and take the diving-only average depth and the water temperature range from the dive record, as the Suunto app shows them. They are used under the same agreement check as the dive time; the temperatures only as a sane pair. --- src/suunto_nautic_parser.c | 62 ++++++++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/src/suunto_nautic_parser.c b/src/suunto_nautic_parser.c index 8b44cfa..09bd527 100644 --- a/src/suunto_nautic_parser.c +++ b/src/suunto_nautic_parser.c @@ -128,23 +128,30 @@ // gives low > high. #define SUMMARY_GF_LOW 0x35 // uint16 LE, % #define SUMMARY_GF_HIGH 0x33 // uint16 LE, % +// Water type (uint8): 0 fresh, 1 EN13319, 2 salt. +#define SUMMARY_WATER_TYPE 0x3E // Gas/cylinder records: an array starting at SUMMARY_GAS_BASE, one 45-byte // record per configured gas. The record index equals the cylinder slot / -// GasNumber, so tank i uses gas i. Within a record: O2% at +1, He% at +2, -// PO2-max (float32 LE, bar) at +5, cylinder water capacity (float32 LE, m^3) -// at +9. Confirmed on two real dual-gas dives (Air 12 L + NX26 5.7 L, two +// GasNumber, so tank i uses gas i. Within a record: O2% (int16 LE) at +1, +// He% (int16 LE) at +3, PO2-max (float32 LE, bar) at +5, cylinder water +// capacity (float32 LE, m^3) at +9, as the watch's own /Summary schema lays +// them out. Confirmed on two real dual-gas dives (Air 12 L + NX26 5.7 L, two // transmitters -- issue #29/#33/#34); a lone gas dive stops at the first // implausible slot, as before. #define SUMMARY_GAS_BASE 0xC7 #define SUMMARY_GAS_STRIDE 45 -#define SUMMARY_GAS_O2 1 // uint8, % -#define SUMMARY_GAS_HE 2 // uint8, % +#define SUMMARY_GAS_O2 1 // int16 LE, % +#define SUMMARY_GAS_HE 3 // int16 LE, % #define SUMMARY_GAS_VOLUME 9 // float32 LE, m^3 // The dive record, the last /Summary record long enough to hold these, carries // the watch's own dive time and maximum depth (the app's DiveTime and -// Depth.Max), at the same payload offsets on 2040, 2056 and 2342 B summaries. +// Depth.Max), at the same payload offsets on 2040, 2056 and 2342 B summaries, +// along with the app's DepthAverage (diving only) and Temperature.Min/Max. +#define SUMMARY_DIVE_TEMP_MIN 313 // float32 LE, K +#define SUMMARY_DIVE_TEMP_MAX 317 // float32 LE, K #define SUMMARY_DIVE_DIVETIME 321 // float32 LE, s, fractional #define SUMMARY_DIVE_MAXDEPTH 330 // float32 LE, m +#define SUMMARY_DIVE_AVGDEPTH 338 // float32 LE, m typedef struct suunto_nautic_tank_t { unsigned int used; @@ -178,6 +185,8 @@ typedef struct suunto_nautic_parser_t { double gasvolume[MAX_GASMIXES]; // cylinder water capacity, litres (0 = unknown) unsigned int have_decomodel; dc_decomodel_t decomodel; + unsigned int have_salinity; + dc_salinity_t salinity; } suunto_nautic_parser_t; typedef struct sbem_chunk_t { @@ -325,7 +334,7 @@ suunto_nautic_summary_dive_record (const unsigned char *sbem, size_t size) } if (length > size - pos - header) break; - if (length >= SUMMARY_DIVE_MAXDEPTH + 4) + if (length >= SUMMARY_DIVE_AVGDEPTH + 4) record = sbem + pos + header; pos += header + length; } @@ -350,13 +359,23 @@ suunto_nautic_parse_summary (suunto_nautic_parser_t *parser, const unsigned char parser->have_decomodel = 1; } + if (size > SUMMARY_WATER_TYPE) { + unsigned int type = sbem[SUMMARY_WATER_TYPE]; + if (type <= 2) { + static const double density[] = {1000.0, 1020.0, 1030.0}; + parser->salinity.type = type ? DC_WATER_SALT : DC_WATER_FRESH; + parser->salinity.density = density[type]; + parser->have_salinity = 1; + } + } + for (unsigned int i = 0; i < MAX_GASMIXES; i++) { size_t base = SUMMARY_GAS_BASE + (size_t) i * SUMMARY_GAS_STRIDE; - if (base + SUMMARY_GAS_O2 >= size) + if (base + SUMMARY_GAS_HE + 2 > size) break; - unsigned int o2 = sbem[base + SUMMARY_GAS_O2]; - unsigned int he = sbem[base + SUMMARY_GAS_HE]; - if (o2 < 1 || o2 > 100 || he > 100 || o2 + he > 100) + int o2 = (int16_t) array_uint16_le (sbem + base + SUMMARY_GAS_O2); + int he = (int16_t) array_uint16_le (sbem + base + SUMMARY_GAS_HE); + if (o2 < 1 || o2 > 100 || he < 0 || he > 100 || o2 + he > 100) break; // unused/implausible slot -- stop unsigned int g = parser->ngasmixes; parser->gasmix[g].oxygen = o2 / 100.0; @@ -900,6 +919,8 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback // Gradient factors and gas mixes from the appended /Summary section. parser->ngasmixes = 0; parser->have_decomodel = 0; + parser->have_salinity = 0; + memset (&parser->salinity, 0, sizeof (parser->salinity)); memset (parser->gasmix, 0, sizeof (parser->gasmix)); memset (parser->gasvolume, 0, sizeof (parser->gasvolume)); memset (&parser->decomodel, 0, sizeof (parser->decomodel)); @@ -910,7 +931,10 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback // The watch's maximum is deeper than any logged sample (the app shows // it). Trusted only on a record whose dive time agrees with the - // profile's, and when it is close to the sampled maximum. + // profile's, and when it is close to the sampled maximum. The same + // record's average depth and water temperature range are the app's + // figures too; the temperatures are taken only as a sane pair, since + // one Ocean dive records its minimum above its maximum. const unsigned char *record = suunto_nautic_summary_dive_record (sbem, sbem_size); if (record && parser->divetime > 0) { double divetime = array_float_le (record + SUMMARY_DIVE_DIVETIME); @@ -921,6 +945,15 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback parser->divetime = (unsigned int) divetime; if (maxdepth > 0.0 && depth >= maxdepth - 0.5 && depth <= maxdepth + 3.0) parser->maxdepth = depth; + double avgdepth = array_float_le (record + SUMMARY_DIVE_AVGDEPTH); + if (avgdepth > 0.0 && avgdepth <= parser->maxdepth) + parser->avgdepth = avgdepth; + double tmin = array_float_le (record + SUMMARY_DIVE_TEMP_MIN); + double tmax = array_float_le (record + SUMMARY_DIVE_TEMP_MAX); + if (have_temperature && tmin > 250.0 && tmax < 330.0 && tmin <= tmax) { + parser->temperature_minimum = tmin - 273.15; + parser->temperature_maximum = tmax - 273.15; + } } } } @@ -1047,6 +1080,11 @@ suunto_nautic_parser_get_field (dc_parser_t *abstract, dc_field_type_t type, uns case DC_FIELD_DIVEMODE: *((dc_divemode_t *) value) = parser->divemode; break; + case DC_FIELD_SALINITY: + if (!parser->have_salinity) + return DC_STATUS_UNSUPPORTED; + *((dc_salinity_t *) value) = parser->salinity; + break; default: return DC_STATUS_UNSUPPORTED; } From 457b5f09493ef654051ac3aa74ebebd11ec078c5 Mon Sep 17 00:00:00 2001 From: Latisha Date: Sun, 11 Oct 2026 12:04:04 +0800 Subject: [PATCH 3/3] suunto_nautic: end pressure as the watch records it at the end of the dive The end pressure was the tank's last reading, but the watch keeps logging on the surface after the dive, where a shut and purged regulator reads about 1 bar (1791129285 ended at 0.9 bar against the app's 121.5). Take the last reading while Diving instead, and the watch's own end pressure from the DiveFooter's gas records when it is plausible. The DiveFooter's start pressure isn't used: it keeps a transmitter's pre-pairing dropout. --- src/suunto_nautic_parser.c | 67 +++++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/src/suunto_nautic_parser.c b/src/suunto_nautic_parser.c index 09bd527..c9eb8de 100644 --- a/src/suunto_nautic_parser.c +++ b/src/suunto_nautic_parser.c @@ -152,12 +152,21 @@ #define SUMMARY_DIVE_DIVETIME 321 // float32 LE, s, fractional #define SUMMARY_DIVE_MAXDEPTH 330 // float32 LE, m #define SUMMARY_DIVE_AVGDEPTH 338 // float32 LE, m +// The DiveFooter (the second record) repeats the gas records from +282, with +// each transmitter's start/end pressure: tank 1 at +25/+29, tank 2 at +33/+37 +// (int32 LE, Pa). The end pressures are the app's; the start pressures keep a +// transmitter's pre-pairing dropout value (9 bar on 1788683271), so they aren't used. +#define SUMMARY_FOOTER_RECORD 1 +#define SUMMARY_FOOTER_GAS_BASE 282 +#define SUMMARY_FOOTER_GAS_END1 29 +#define SUMMARY_FOOTER_GAS_END2 37 typedef struct suunto_nautic_tank_t { unsigned int used; double beginpressure; // bar double endpressure; // bar unsigned int gasmix; // gas index (== cylinder slot / GasNumber) + unsigned int field; // 0 = Pressure (first transmitter), 1 = Pressure2 } suunto_nautic_tank_t; typedef struct suunto_nautic_parser_t { @@ -315,9 +324,34 @@ suunto_nautic_find_summary (const unsigned char *data, size_t size) return size; } -// The /Summary's dive record: its records are plain [id][len] TLVs (the -// profile's fixed-length table doesn't apply), ending in trailing bytes that -// don't form a whole record. +// The /Summary's records are plain [id][len] TLVs (the profile's fixed-length +// table doesn't apply), ending in trailing bytes that don't form a whole +// record. Record ids differ between models, so records are found by position. +static const unsigned char * +suunto_nautic_summary_record (const unsigned char *sbem, size_t size, unsigned int index, size_t *out_length) +{ + size_t pos = SBEM_MAGIC_SIZE; + for (unsigned int i = 0; pos + 2 <= size; i++) { + size_t length = sbem[pos + 1]; + size_t header = 2; + if (length == 255) { + if (pos + 6 > size) + break; + length = array_uint32_le (sbem + pos + 2); + header = 6; + } + if (length > size - pos - header) + break; + if (i == index) { + *out_length = length; + return sbem + pos + header; + } + pos += header + length; + } + return NULL; +} + +// The /Summary's dive record: the last record long enough to hold its fields. static const unsigned char * suunto_nautic_summary_dive_record (const unsigned char *sbem, size_t size) { @@ -505,10 +539,15 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback // it has read non-zero at least twice (p2_first holds the first value). int tankmap[MAX_TANKS * 2]; int p2_first[MAX_TANKS]; + // The last reading of each tank while Diving: readings taken on the surface + // after the dive can be of a closed, purged regulator (~1 bar). + double diving_end[MAX_TANKS]; for (unsigned int ti = 0; ti < MAX_TANKS * 2; ti++) tankmap[ti] = -1; - for (unsigned int ti = 0; ti < MAX_TANKS; ti++) + for (unsigned int ti = 0; ti < MAX_TANKS; ti++) { p2_first[ti] = -1; + diving_end[ti] = -1.0; + } unsigned int have_location = 0; dc_location_t location = {0}; @@ -640,6 +679,7 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback // Cylinder slot index == GasNumber, so this tank // breathes gas i (linked to gasmix[i] below). tank[ntanks].gasmix = i; + tank[ntanks].field = field; // Seed begin from the first observed reading (the deferred // one for Pressure2); refined to the max below. tank[ntanks].beginpressure = (field == 1 && p2_first[i] >= 0) @@ -648,6 +688,8 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback } unsigned int t = (unsigned int) tankmap[key]; tank[t].endpressure = bar; + if (dive_state == DIVE_STATE_DIVING) + diving_end[t] = bar; // Begin pressure = the highest reading seen: a cylinder only // loses pressure during a dive, so the max is the true start. // This ignores an initial dropout where a transmitter that @@ -907,6 +949,10 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback parser->temperature_surface = temperature_surface; parser->temperature_minimum = temperature_minimum; parser->temperature_maximum = temperature_maximum; + for (unsigned int t = 0; t < ntanks; t++) { + if (diving_end[t] >= 0.0) + tank[t].endpressure = diving_end[t]; + } parser->ntanks = ntanks; memcpy (parser->tank, tank, sizeof (tank)); parser->have_location = have_location; @@ -954,6 +1000,19 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback parser->temperature_minimum = tmin - 273.15; parser->temperature_maximum = tmax - 273.15; } + + size_t footer_size = 0; + const unsigned char *footer = suunto_nautic_summary_record (sbem, sbem_size, SUMMARY_FOOTER_RECORD, &footer_size); + for (unsigned int t = 0; footer && t < parser->ntanks; t++) { + suunto_nautic_tank_t *tk = &parser->tank[t]; + size_t base = SUMMARY_FOOTER_GAS_BASE + (size_t) tk->gasmix * SUMMARY_GAS_STRIDE; + if (base + SUMMARY_GAS_STRIDE > footer_size) + continue; + int pa = (int) array_uint32_le (footer + base + (tk->field ? SUMMARY_FOOTER_GAS_END2 : SUMMARY_FOOTER_GAS_END1)); + double bar = pa / 100000.0; + if (bar > 0.0 && bar <= tk->beginpressure + 1.0) + tk->endpressure = bar; + } } } }