From 9c7be0ea137f34f89ca4d140ed3f2b9ed5d74ec4 Mon Sep 17 00:00:00 2001 From: urbamax Date: Fri, 4 Sep 2026 08:35:38 +0200 Subject: [PATCH 1/5] suunto_nautic: emit DC_EVENT_DEVINFO (firmware + serial) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A short fetch of GET /Info returns a device-identity record — confirmed on a live Nautic (firmware 2.55.46): Suunto\0 Nautic\0 Vaasa\0 T\0 2604C3003306\0 ... 2.55.46 ... in one 303-byte DATA frame. Take the first "N.N.N" token as the firmware and the first 12-char uppercase-hex token (which precedes the firmware) as the serial, and announce them via DC_EVENT_DEVINFO. Best-effort — every failure path is non-fatal. dc_event_devinfo_t is unsigned-int only, so the firmware is packed (a<<16)|(b<<8)|c and the hex serial truncated to its low 32 bits. --- src/suunto_nautic.c | 112 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/src/suunto_nautic.c b/src/suunto_nautic.c index ae7d17e..132f682 100644 --- a/src/suunto_nautic.c +++ b/src/suunto_nautic.c @@ -1086,6 +1086,115 @@ suunto_nautic_device_list (dc_device_t *abstract, dc_buffer_t *out) return DC_STATUS_SUCCESS; } +// True for a token that is exactly "..", each part +// < 256; on success fills a/b/c. +static int +suunto_nautic_parse_version (const char *tok, size_t len, unsigned int *a, unsigned int *b, unsigned int *c) +{ + unsigned int part[3] = {0}, idx = 0, digits = 0; + for (size_t i = 0; i < len; i++) { + char ch = tok[i]; + if (ch >= '0' && ch <= '9') { + part[idx] = part[idx] * 10 + (unsigned int) (ch - '0'); + if (part[idx] > 255 || ++digits > 3) + return 0; + } else if (ch == '.') { + if (digits == 0 || ++idx > 2) + return 0; + digits = 0; + } else { + return 0; + } + } + if (idx != 2 || digits == 0) + return 0; + *a = part[0]; *b = part[1]; *c = part[2]; + return 1; +} + +// True for a token that is exactly 12 chars, all [0-9A-F] -- the watch serial +// form, e.g. "2604C3003306". +static int +suunto_nautic_is_serial (const char *tok, size_t len) +{ + if (len != 12) + return 0; + for (size_t i = 0; i < len; i++) { + char ch = tok[i]; + if (!((ch >= '0' && ch <= '9') || (ch >= 'A' && ch <= 'F'))) + return 0; + } + return 1; +} + +/* + * Best-effort device info. A short fetch of /Info returns a device-identity + * record: a run of NUL-separated strings mixed in with binary framing bytes, + * + * Suunto\0 Nautic\0 Vaasa\0 T\0 2604C3003306\0 01385D42<...>\0 + * 2.55.46\0 \0 ... SIM\0 \0 BID\0 \0 + * BLE Mac Address\0 \0 WiFi Mac Address\0 \0 + * + * (confirmed on a live Nautic, firmware 2.55.46, via fetch_device_info.py). + * The watch serial is the first 12-char uppercase-hex token and the firmware + * the first "N.N.N" token; the serial always precedes the firmware, and the + * BLE/WiFi MACs (also 12 hex) come after it -- so stop looking for a serial + * once the firmware token is seen. Layered on top of the download; every + * failure path here is non-fatal. + * + * dc_event_devinfo_t carries unsigned ints only: the firmware is packed + * (a << 16) | (b << 8) | c and the hex serial truncated to its low 32 bits. + * A consumer wanting the faithful strings should read the BLE advertised + * name (serial) or GET /Info directly (firmware). + */ +static void +suunto_nautic_emit_devinfo (dc_device_t *abstract) +{ + dc_buffer_t *info = dc_buffer_new (0); + if (info == NULL) + return; + + if (suunto_nautic_device_short_fetch (abstract, "/Info", info) != DC_STATUS_SUCCESS) { + dc_buffer_free (info); + return; // not fatal -- just no device info this time + } + + const unsigned char *d = dc_buffer_get_data (info); + size_t n = dc_buffer_get_size (info); + + dc_event_devinfo_t devinfo; + memset (&devinfo, 0, sizeof (devinfo)); + + // Walk NUL-delimited tokens; the record's strings are NUL-terminated, and + // the binary framing bytes between them fail both tests. + size_t start = 0; + for (size_t i = 0; i < n; i++) { + if (d[i] != 0) + continue; + const char *tok = (const char *) (d + start); + size_t toklen = i - start; + unsigned int a, b, c; + if (!devinfo.firmware && suunto_nautic_parse_version (tok, toklen, &a, &b, &c)) + devinfo.firmware = (a << 16) | (b << 8) | c; + else if (!devinfo.firmware && !devinfo.serial && suunto_nautic_is_serial (tok, toklen)) { + char buf[13]; + memcpy (buf, tok, 12); + buf[12] = 0; + devinfo.serial = (unsigned int) strtoul (buf, NULL, 16); + } + start = i + 1; + } + + dc_buffer_free (info); + + if (devinfo.firmware || devinfo.serial) { + INFO (abstract->context, "Device info: firmware=%u.%u.%u serial=0x%08x", + (devinfo.firmware >> 16) & 0xFF, (devinfo.firmware >> 8) & 0xFF, + devinfo.firmware & 0xFF, devinfo.serial); + device_event_emit (abstract, DC_EVENT_DEVINFO, &devinfo); + } +} + static dc_status_t suunto_nautic_device_foreach (dc_device_t *abstract, dc_dive_callback_t callback, void *userdata) { @@ -1116,6 +1225,9 @@ suunto_nautic_device_foreach (dc_device_t *abstract, dc_dive_callback_t callback device_event_emit (abstract, DC_EVENT_VENDOR, &vendor); dc_buffer_free (mode); + // Best-effort firmware / serial via DC_EVENT_DEVINFO; never fatal. + suunto_nautic_emit_devinfo (abstract); + progress.current = 1; device_event_emit (abstract, DC_EVENT_PROGRESS, &progress); From b9e637dbcd9834b73b84d611541dbdf3311ac5e2 Mon Sep 17 00:00:00 2001 From: urbamax Date: Mon, 7 Sep 2026 23:47:50 +0200 Subject: [PATCH 2/5] suunto_nautic: report the dive mode (was always freedive) The parser never answered DC_FIELD_DIVEMODE, so the consumer was left with the dc_divemode_t zero value -- FREEDIVE -- and every scuba dive imported as an apnea dive (seen on nandodiver's captures, deepsealabs/libdc-swift#29). Read the CHUNK_ACTIVITY (0x08) sport id -- the app's ActivityType, e.g. 51 scuba, 61 free diving, 62 mermaiding -- and map 61/62 to DC_DIVEMODE_FREEDIVE, everything else (and a stream with no activity chunk, since the Nautic/Ocean are recreational OC computers) to DC_DIVEMODE_OC. Co-Authored-By: Claude Sonnet 5 (cherry picked from commit 3319bab08bcc4042aaf7cd0e2d8b8a3bd45f8e86) --- src/suunto_nautic_parser.c | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/suunto_nautic_parser.c b/src/suunto_nautic_parser.c index e68ccb1..bd0fce5 100644 --- a/src/suunto_nautic_parser.c +++ b/src/suunto_nautic_parser.c @@ -59,7 +59,11 @@ #define SBEM_MAGIC_SIZE 8 #define CHUNK_TIMELINE_BASE 0x01 -#define CHUNK_ACTIVITY 0x08 +#define CHUNK_ACTIVITY 0x08 // [timeDelta:2][sportId:1][customModeId: ascii] +// Suunto sport ids seen on the Nautic/Ocean, from the app's ActivityType: +// 51 = scuba, 61 = free diving, 62 = mermaiding. Only the last two are apnea. +#define SPORT_ID_FREEDIVE 61 +#define SPORT_ID_MERMAIDING 62 #define CHUNK_GPS 0x0B #define CHUNK_GPS_ACCURACY 0x0E // [timeDelta:2][dEHPE:int8][dEVPE:int8][?:2] #define CHUNK_BATTERY 0x14 // [timeDelta:2][current:int16][voltage:uint16 mV][charge:uint8 %] @@ -158,6 +162,7 @@ typedef struct suunto_nautic_parser_t { double atmospheric; // bar unsigned int have_datetime; dc_ticks_t datetime; // dive start, UNIX seconds + dc_divemode_t divemode; // from the CHUNK_ACTIVITY sport id; OC unless apnea // From the /Summary SBEM section appended after the profile, if present. unsigned int ngasmixes; dc_gasmix_t gasmix[MAX_GASMIXES]; @@ -458,6 +463,12 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback unsigned int have_datetime = 0; + // Open circuit unless a CHUNK_ACTIVITY sport id says the dive was apnea. + // The Nautic/Ocean are recreational OC computers (no CCR/SCR), and with no + // activity chunk at all OC is the right default -- far better than the + // dc_divemode_t zero value (freedive) a missing field leaves behind. + dc_divemode_t divemode = DC_DIVEMODE_OC; + // GPS horizontal/vertical position error, int8-delta-accumulated (chunk 0x0E). int ehpe = 0, evpe = 0; @@ -774,6 +785,15 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback // SurfacePressure (offset 2) is used; last one logged wins. have_atmospheric = 1; atmospheric = array_float_le (chunk.data + 2) / 100000.0; + } else if (chunk.id == CHUNK_ACTIVITY && chunk.size >= 3) { + // [timeDelta:2][sportId:1][customModeId: ascii]. The sport id + // is the app's ActivityType; 61/62 are the apnea sports, every + // other diving id is open circuit. + unsigned int sport = chunk.data[2]; + if (sport == SPORT_ID_FREEDIVE || sport == SPORT_ID_MERMAIDING) + divemode = DC_DIVEMODE_FREEDIVE; + else + divemode = DC_DIVEMODE_OC; } } @@ -799,6 +819,7 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback parser->have_atmospheric = have_atmospheric; parser->atmospheric = atmospheric; parser->have_datetime = have_datetime; + parser->divemode = divemode; // Gradient factors and gas mixes from the appended /Summary section. parser->ngasmixes = 0; @@ -925,6 +946,9 @@ suunto_nautic_parser_get_field (dc_parser_t *abstract, dc_field_type_t type, uns return DC_STATUS_UNSUPPORTED; *((dc_decomodel_t *) value) = parser->decomodel; break; + case DC_FIELD_DIVEMODE: + *((dc_divemode_t *) value) = parser->divemode; + break; default: return DC_STATUS_UNSUPPORTED; } From 81e4c313a2342d49e98f1d257ae1fd6395e3d9df Mon Sep 17 00:00:00 2001 From: urbamax Date: Tue, 8 Sep 2026 12:01:21 +0200 Subject: [PATCH 3/5] suunto_nautic: decode gas time remaining (DC_SAMPLE_RBT) Each 18-byte cylinder record in the 0x16 extended-status chunk carries a uint32 LE (seconds) at +10 -- the gas time remaining, the app's Cylinders[].GasTime. 0xFFFFFFFF means not computed (no AI, or not enough data yet). Emit it for the primary cylinder as RBT minutes, the same way suunto_eonsteel does. Byte-exact against the app export's per-sample GasTime on real pod dives (deepsealabs/libdc-swift#29), and absent exactly on the no-transmitter dives. Co-Authored-By: Claude Sonnet 5 --- src/suunto_nautic_parser.c | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/suunto_nautic_parser.c b/src/suunto_nautic_parser.c index bd0fce5..0113227 100644 --- a/src/suunto_nautic_parser.c +++ b/src/suunto_nautic_parser.c @@ -29,12 +29,12 @@ * immediately, before the value. * * Decoded chunks: 0x12 (1Hz absolute pressure / temperature), 0x16 - * (depth, cylinder pressures, NDL, time-to-surface), 0x17 (surface - * pressure), 0x0B (GPS), plus the dynamically-assigned dive-event - * subgroups. Chunks 0x08 (activity), 0x0E (satellite info) and 0x14 - * (battery) have fixed lengths that are used for resync (see - * suunto_nautic_sbem_fixed_length) but map to no dc_field/dc_sample and - * are not otherwise decoded. Chunks 0x23/0x24 are raw accelerometer / + * (depth, cylinder pressures, gas time remaining, NDL, time-to-surface), + * 0x17 (surface pressure), 0x0B (GPS), 0x08 (activity -> dive mode), plus + * the dynamically-assigned dive-event subgroups. Chunks 0x0E (satellite + * info) and 0x14 (battery) have fixed lengths that are used for resync + * (see suunto_nautic_sbem_fixed_length) but map to no dc_field/dc_sample + * and are not otherwise decoded. Chunks 0x23/0x24 are raw accelerometer / * gyroscope dumps for client-side dead reckoning, emitted through * DC_SAMPLE_VENDOR. Unknown chunk ids are skipped, so extending the * decoder is additive. @@ -544,6 +544,23 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback break; // full tank record doesn't fit this chunk if (chunk.data[base] != i) break; // not a real tank slot + + // Gas time remaining: uint32 LE seconds at record +10, for + // the primary cylinder. 0xFFFFFFFF means not computed (no + // AI, or not enough data yet). This is the app's + // Cylinders[].GasTime; exposed as RBT minutes, the same way + // suunto_eonsteel does. + if (i == 0 && callback) { + unsigned int gastime = array_uint32_le (chunk.data + base + 10); + if (gastime != 0xFFFFFFFF && gastime != 0) { + dc_sample_value_t sample = {0}; + sample.time = (unsigned int) time_ms; + callback (DC_SAMPLE_TIME, &sample, userdata); + sample.rbt = gastime / 60; + callback (DC_SAMPLE_RBT, &sample, userdata); + } + } + for (unsigned int field = 0; field < 2; field++) { unsigned int pressure_pa = array_uint32_le (chunk.data + base + 2 + field * 4); if (pressure_pa == 0) From d08d4c5048c7c4dc336c5f0d5196137a2ce35808 Mon Sep 17 00:00:00 2001 From: urbamax Date: Tue, 8 Sep 2026 12:02:13 +0200 Subject: [PATCH 4/5] suunto_nautic: decode heart rate (0x0F -> DC_SAMPLE_HEARTBEAT) Chunk 0x0F is [timeDelta:2][hr:uint8 bpm] -- the Suunto Ocean's optical wrist heart rate, one sample per second. The Nautic and Nautic S have no HR sensor, so the chunk never appears on those and nothing changes for them. Verified byte-exact against the app export's per-sample HR on a real Ocean dive (deepsealabs/libdc-swift#29): 2413 samples, first 30 values identical (89, 89, 89, 90, 89, 91, ...), full range 66-113 bpm. Co-Authored-By: Claude Sonnet 5 --- src/suunto_nautic_parser.c | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/suunto_nautic_parser.c b/src/suunto_nautic_parser.c index 0113227..a319cf3 100644 --- a/src/suunto_nautic_parser.c +++ b/src/suunto_nautic_parser.c @@ -30,8 +30,9 @@ * * Decoded chunks: 0x12 (1Hz absolute pressure / temperature), 0x16 * (depth, cylinder pressures, gas time remaining, NDL, time-to-surface), - * 0x17 (surface pressure), 0x0B (GPS), 0x08 (activity -> dive mode), plus - * the dynamically-assigned dive-event subgroups. Chunks 0x0E (satellite + * 0x17 (surface pressure), 0x0B (GPS), 0x0F (heart rate, Ocean only), + * 0x08 (activity -> dive mode), plus the dynamically-assigned dive-event + * subgroups. Chunks 0x0E (satellite * info) and 0x14 (battery) have fixed lengths that are used for resync * (see suunto_nautic_sbem_fixed_length) but map to no dc_field/dc_sample * and are not otherwise decoded. Chunks 0x23/0x24 are raw accelerometer / @@ -66,6 +67,7 @@ #define SPORT_ID_MERMAIDING 62 #define CHUNK_GPS 0x0B #define CHUNK_GPS_ACCURACY 0x0E // [timeDelta:2][dEHPE:int8][dEVPE:int8][?:2] +#define CHUNK_HEARTRATE 0x0F // [timeDelta:2][hr:uint8 bpm] -- Ocean wrist HR only #define CHUNK_BATTERY 0x14 // [timeDelta:2][current:int16][voltage:uint16 mV][charge:uint8 %] // High-rate IMU: [timeDelta:2][algoTS:uint32][accel/gyro/mag X,Y,Z:int16], a // 24-byte payload. The chunk id is FIRMWARE-DEPENDENT: 0x23 on the 195-byte @@ -738,6 +740,19 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback sample.event.value = (unsigned int) (int16_t) array_uint16_le (chunk.data + 2); callback (DC_SAMPLE_EVENT, &sample, userdata); } + } else if (chunk.id == CHUNK_HEARTRATE && chunk.size >= 3) { + // [timeDelta:2][hr:uint8 bpm]. Optical wrist HR, present only on + // the Suunto Ocean (the Nautic / Nautic S have no HR sensor, so + // the chunk never appears there). Byte-exact against the app + // export's per-sample HR on a real Ocean dive (66-113 bpm). + unsigned int hr = chunk.data[2]; + if (hr && callback) { + dc_sample_value_t sample = {0}; + sample.time = (unsigned int) time_ms; + callback (DC_SAMPLE_TIME, &sample, userdata); + sample.heartbeat = hr; + callback (DC_SAMPLE_HEARTBEAT, &sample, userdata); + } } else if (chunk.id == CHUNK_BATTERY && chunk.size >= 7) { // Battery telemetry -> DC_SAMPLE_VENDOR kind 1. // (Current at chunk.data+2 is int16 but its scale isn't confirmed, From ee747a9ab8d4a1f8ff359cffbf970fba2d2a002a Mon Sep 17 00:00:00 2001 From: urbamax Date: Tue, 8 Sep 2026 12:03:35 +0200 Subject: [PATCH 5/5] suunto_nautic: surface temperature + gas-mix sample on a switch - DC_FIELD_TEMPERATURE_SURFACE: the first temperature reading, taken at the surface at dive start. Verified against the app export's first sample on real dives (24.3 / 20.9 / 29.7 C). - DC_SAMPLE_GASMIX: emit the active gas-mix index on a CHUNK_GAS_SWITCH, the modern channel, alongside the existing SAMPLE_EVENT_GASCHANGE that older consumers read. Co-Authored-By: Claude Sonnet 5 --- src/suunto_nautic_parser.c | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/suunto_nautic_parser.c b/src/suunto_nautic_parser.c index a319cf3..1c73053 100644 --- a/src/suunto_nautic_parser.c +++ b/src/suunto_nautic_parser.c @@ -154,6 +154,7 @@ typedef struct suunto_nautic_parser_t { double maxdepth; // meters double avgdepth; // meters unsigned int have_temperature; + double temperature_surface; // first (surface) reading double temperature_minimum; double temperature_maximum; unsigned int ntanks; @@ -437,6 +438,7 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback unsigned int depth_count = 0; unsigned int have_temperature = 0; + double temperature_surface = 0.0; double temperature_minimum = 0.0; double temperature_maximum = 0.0; @@ -485,6 +487,8 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback double temperature = array_uint16_le (chunk.data + 16) / 100.0 - 273.15; if (!have_temperature) { + // The first reading is taken at/near the surface at dive start. + temperature_surface = temperature; temperature_minimum = temperature_maximum = temperature; have_temperature = 1; } else { @@ -730,14 +734,20 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback callback (DC_SAMPLE_EVENT, &sample, userdata); } } else if (chunk.id == CHUNK_GAS_SWITCH && chunk.size >= 4) { - // [timeDelta:2][gasnumber:int16 LE]. - if (callback) { + // [timeDelta:2][gasnumber:int16 LE] -- 0-based, and equal to the + // cylinder slot / gas-mix index. + int gasnum = (int16_t) array_uint16_le (chunk.data + 2); + if (gasnum >= 0 && callback) { dc_sample_value_t sample = {0}; sample.time = (unsigned int) time_ms; callback (DC_SAMPLE_TIME, &sample, userdata); + // Modern channel: the active gas-mix index. + sample.gasmix = (unsigned int) gasnum; + callback (DC_SAMPLE_GASMIX, &sample, userdata); + // Legacy channel, for consumers that only read events. sample.event.type = SAMPLE_EVENT_GASCHANGE; sample.event.flags = SAMPLE_FLAGS_BEGIN; - sample.event.value = (unsigned int) (int16_t) array_uint16_le (chunk.data + 2); + sample.event.value = (unsigned int) gasnum; callback (DC_SAMPLE_EVENT, &sample, userdata); } } else if (chunk.id == CHUNK_HEARTRATE && chunk.size >= 3) { @@ -842,6 +852,7 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback parser->maxdepth = maxdepth; parser->avgdepth = depth_count ? depth_sum / depth_count : 0.0; parser->have_temperature = have_temperature; + parser->temperature_surface = temperature_surface; parser->temperature_minimum = temperature_minimum; parser->temperature_maximum = temperature_maximum; parser->ntanks = ntanks; @@ -919,6 +930,11 @@ suunto_nautic_parser_get_field (dc_parser_t *abstract, dc_field_type_t type, uns case DC_FIELD_AVGDEPTH: *((double *) value) = parser->avgdepth; break; + case DC_FIELD_TEMPERATURE_SURFACE: + if (!parser->have_temperature) + return DC_STATUS_UNSUPPORTED; + *((double *) value) = parser->temperature_surface; + break; case DC_FIELD_TEMPERATURE_MINIMUM: if (!parser->have_temperature) return DC_STATUS_UNSUPPORTED;