diff --git a/configure.ac b/configure.ac
index b5a0bff9..5932bc40 100644
--- a/configure.ac
+++ b/configure.ac
@@ -18,7 +18,7 @@ AC_CONFIG_HEADERS([config.h])
AC_CONFIG_MACRO_DIR([m4])
# Initialize automake.
-AM_INIT_AUTOMAKE([-Wall -Werror foreign])
+AM_INIT_AUTOMAKE([-Wall -Werror foreign subdir-objects])
# Fix for automake >= 1.12
m4_ifdef([AM_PROG_AR], [AM_PROG_AR])
diff --git a/contrib/android/Android.mk b/contrib/android/Android.mk
index fd6e8b4a..36984897 100644
--- a/contrib/android/Android.mk
+++ b/contrib/android/Android.mk
@@ -102,6 +102,9 @@ LOCAL_SRC_FILES := \
src/suunto_eon_parser.c \
src/suunto_eonsteel.c \
src/suunto_eonsteel_parser.c \
+ src/suunto_nautic.c \
+ src/suunto_nautic_parser.c \
+ src/heatshrink/heatshrink_decoder.c \
src/suunto_solution.c \
src/suunto_solution_parser.c \
src/suunto_vyper2.c \
diff --git a/contrib/msvc/libdivecomputer.vcxproj b/contrib/msvc/libdivecomputer.vcxproj
index b1571952..a6978ef9 100644
--- a/contrib/msvc/libdivecomputer.vcxproj
+++ b/contrib/msvc/libdivecomputer.vcxproj
@@ -269,6 +269,9 @@
+
+
+
@@ -380,6 +383,10 @@
+
+
+
+
diff --git a/include/libdivecomputer/common.h b/include/libdivecomputer/common.h
index 0fcb11cc..fcef5bf6 100644
--- a/include/libdivecomputer/common.h
+++ b/include/libdivecomputer/common.h
@@ -123,6 +123,8 @@ typedef enum dc_family_t {
DC_FAMILY_DIVESOFT_FREEDOM = (23 << 16),
/* Halcyon Symbios */
DC_FAMILY_HALCYON_SYMBIOS = (24 << 16),
+ /* Suunto Nautic */
+ DC_FAMILY_SUUNTO_NAUTIC = (25 << 16),
} dc_family_t;
#ifdef __cplusplus
diff --git a/include/libdivecomputer/parser.h b/include/libdivecomputer/parser.h
index 4bcbdbd2..702aebaf 100644
--- a/include/libdivecomputer/parser.h
+++ b/include/libdivecomputer/parser.h
@@ -112,7 +112,8 @@ typedef enum parser_sample_vendor_t {
SAMPLE_VENDOR_UWATEC_SMART,
SAMPLE_VENDOR_OCEANIC_VTPRO,
SAMPLE_VENDOR_OCEANIC_VEO250,
- SAMPLE_VENDOR_OCEANIC_ATOM2
+ SAMPLE_VENDOR_OCEANIC_ATOM2,
+ SAMPLE_VENDOR_SUUNTO_NAUTIC
} parser_sample_vendor_t;
typedef enum dc_water_t {
diff --git a/src/Makefile.am b/src/Makefile.am
index 583546b1..2b5c32eb 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -83,6 +83,9 @@ libdivecomputer_la_SOURCES = \
oceans_s1.h oceans_s1.c oceans_s1_parser.c \
divesoft_freedom.h divesoft_freedom.c divesoft_freedom_parser.c \
halcyon_symbios.h halcyon_symbios.c halcyon_symbios_parser.c \
+ suunto_nautic.h suunto_nautic.c suunto_nautic_parser.c \
+ heatshrink/heatshrink_decoder.h heatshrink/heatshrink_decoder.c \
+ heatshrink/heatshrink_common.h heatshrink/heatshrink_config.h \
hdlc.h hdlc.c \
packet.h packet.c \
socket.h socket.c \
diff --git a/src/descriptor.c b/src/descriptor.c
index ea8a653a..f1aad0f7 100644
--- a/src/descriptor.c
+++ b/src/descriptor.c
@@ -63,6 +63,7 @@ static int dc_filter_oceans (const dc_descriptor_t *descriptor, dc_transport_t t
static int dc_filter_divesoft (const dc_descriptor_t *descriptor, dc_transport_t transport, const void *userdata);
static int dc_filter_cressi (const dc_descriptor_t *descriptor, dc_transport_t transport, const void *userdata);
static int dc_filter_halcyon (const dc_descriptor_t *descriptor, dc_transport_t transport, const void *userdata);
+static int dc_filter_suunto_nautic (const dc_descriptor_t *descriptor, dc_transport_t transport, const void *userdata);
static int dc_filter_seac (const dc_descriptor_t *descriptor, dc_transport_t transport, const void *userdata);
static dc_status_t dc_descriptor_iterator_next (dc_iterator_t *iterator, void *item);
@@ -492,6 +493,9 @@ static const dc_descriptor_t g_descriptors[] = {
/* Halcyon Symbios */
{"Halcyon", "Symbios HUD", DC_FAMILY_HALCYON_SYMBIOS, 1, DC_TRANSPORT_BLE, dc_filter_halcyon},
{"Halcyon", "Symbios Handset", DC_FAMILY_HALCYON_SYMBIOS, 7, DC_TRANSPORT_BLE, dc_filter_halcyon},
+ /* Suunto Nautic / Ocean ("Vaasa" generation) */
+ {"Suunto", "Nautic", DC_FAMILY_SUUNTO_NAUTIC, 0, DC_TRANSPORT_BLE, dc_filter_suunto_nautic},
+ {"Suunto", "Ocean", DC_FAMILY_SUUNTO_NAUTIC, 1, DC_TRANSPORT_BLE, dc_filter_suunto_nautic},
};
static int
@@ -965,6 +969,21 @@ dc_filter_halcyon (const dc_descriptor_t *descriptor, dc_transport_t transport,
return 1;
}
+static int
+dc_filter_suunto_nautic (const dc_descriptor_t *descriptor, dc_transport_t transport, const void *userdata)
+{
+ static const char * const bluetooth[] = {
+ "Suunto Nautic",
+ "Suunto Ocean",
+ };
+
+ if (transport == DC_TRANSPORT_BLE) {
+ return DC_FILTER_INTERNAL (userdata, bluetooth, 0, dc_match_prefix);
+ }
+
+ return 1;
+}
+
static int
dc_filter_seac (const dc_descriptor_t *descriptor, dc_transport_t transport, const void *userdata)
{
diff --git a/src/device.c b/src/device.c
index 46ef48ef..ad43bd76 100644
--- a/src/device.c
+++ b/src/device.c
@@ -66,6 +66,7 @@
#include "oceans_s1.h"
#include "divesoft_freedom.h"
#include "halcyon_symbios.h"
+#include "suunto_nautic.h"
#include "device-private.h"
#include "context-private.h"
@@ -250,6 +251,9 @@ dc_device_open (dc_device_t **out, dc_context_t *context, dc_descriptor_t *descr
case DC_FAMILY_HALCYON_SYMBIOS:
rc = halcyon_symbios_device_open (&device, context, iostream);
break;
+ case DC_FAMILY_SUUNTO_NAUTIC:
+ rc = suunto_nautic_device_open (&device, context, iostream);
+ break;
default:
return DC_STATUS_INVALIDARGS;
}
diff --git a/src/heatshrink/LICENSE b/src/heatshrink/LICENSE
new file mode 100644
index 00000000..6b696341
--- /dev/null
+++ b/src/heatshrink/LICENSE
@@ -0,0 +1,14 @@
+Copyright (c) 2013-2015, Scott Vokes
+All rights reserved.
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
diff --git a/src/heatshrink/heatshrink_common.h b/src/heatshrink/heatshrink_common.h
new file mode 100644
index 00000000..243f4470
--- /dev/null
+++ b/src/heatshrink/heatshrink_common.h
@@ -0,0 +1,20 @@
+#ifndef HEATSHRINK_H
+#define HEATSHRINK_H
+
+#define HEATSHRINK_AUTHOR "Scott Vokes "
+#define HEATSHRINK_URL "https://github.com/atomicobject/heatshrink"
+
+/* Version 0.4.1 */
+#define HEATSHRINK_VERSION_MAJOR 0
+#define HEATSHRINK_VERSION_MINOR 4
+#define HEATSHRINK_VERSION_PATCH 1
+
+#define HEATSHRINK_MIN_WINDOW_BITS 4
+#define HEATSHRINK_MAX_WINDOW_BITS 15
+
+#define HEATSHRINK_MIN_LOOKAHEAD_BITS 3
+
+#define HEATSHRINK_LITERAL_MARKER 0x01
+#define HEATSHRINK_BACKREF_MARKER 0x00
+
+#endif
diff --git a/src/heatshrink/heatshrink_config.h b/src/heatshrink/heatshrink_config.h
new file mode 100644
index 00000000..13135b93
--- /dev/null
+++ b/src/heatshrink/heatshrink_config.h
@@ -0,0 +1,26 @@
+#ifndef HEATSHRINK_CONFIG_H
+#define HEATSHRINK_CONFIG_H
+
+/* Should functionality assuming dynamic allocation be used? */
+#ifndef HEATSHRINK_DYNAMIC_ALLOC
+#define HEATSHRINK_DYNAMIC_ALLOC 1
+#endif
+
+#if HEATSHRINK_DYNAMIC_ALLOC
+ /* Optional replacement of malloc/free */
+ #define HEATSHRINK_MALLOC(SZ) malloc(SZ)
+ #define HEATSHRINK_FREE(P, SZ) free(P)
+#else
+ /* Required parameters for static configuration */
+ #define HEATSHRINK_STATIC_INPUT_BUFFER_SIZE 32
+ #define HEATSHRINK_STATIC_WINDOW_BITS 8
+ #define HEATSHRINK_STATIC_LOOKAHEAD_BITS 4
+#endif
+
+/* Turn on logging for debugging. */
+#define HEATSHRINK_DEBUGGING_LOGS 0
+
+/* Use indexing for faster compression. (This requires additional space.) */
+#define HEATSHRINK_USE_INDEX 1
+
+#endif
diff --git a/src/heatshrink/heatshrink_decoder.c b/src/heatshrink/heatshrink_decoder.c
new file mode 100644
index 00000000..0f118cf9
--- /dev/null
+++ b/src/heatshrink/heatshrink_decoder.c
@@ -0,0 +1,367 @@
+#include
+#include
+#include "heatshrink_decoder.h"
+
+/* States for the polling state machine. */
+typedef enum {
+ HSDS_TAG_BIT, /* tag bit */
+ HSDS_YIELD_LITERAL, /* ready to yield literal byte */
+ HSDS_BACKREF_INDEX_MSB, /* most significant byte of index */
+ HSDS_BACKREF_INDEX_LSB, /* least significant byte of index */
+ HSDS_BACKREF_COUNT_MSB, /* most significant byte of count */
+ HSDS_BACKREF_COUNT_LSB, /* least significant byte of count */
+ HSDS_YIELD_BACKREF, /* ready to yield back-reference */
+} HSD_state;
+
+#if HEATSHRINK_DEBUGGING_LOGS
+#include
+#include
+#include
+#define LOG(...) fprintf(stderr, __VA_ARGS__)
+#define ASSERT(X) assert(X)
+static const char *state_names[] = {
+ "tag_bit",
+ "yield_literal",
+ "backref_index_msb",
+ "backref_index_lsb",
+ "backref_count_msb",
+ "backref_count_lsb",
+ "yield_backref",
+};
+#else
+#define LOG(...) /* no-op */
+#define ASSERT(X) /* no-op */
+#endif
+
+typedef struct {
+ uint8_t *buf; /* output buffer */
+ size_t buf_size; /* buffer size */
+ size_t *output_size; /* bytes pushed to buffer, so far */
+} output_info;
+
+#define NO_BITS ((uint16_t)-1)
+
+/* Forward references. */
+static uint16_t get_bits(heatshrink_decoder *hsd, uint8_t count);
+static void push_byte(heatshrink_decoder *hsd, output_info *oi, uint8_t byte);
+
+#if HEATSHRINK_DYNAMIC_ALLOC
+heatshrink_decoder *heatshrink_decoder_alloc(uint16_t input_buffer_size,
+ uint8_t window_sz2,
+ uint8_t lookahead_sz2) {
+ if ((window_sz2 < HEATSHRINK_MIN_WINDOW_BITS) ||
+ (window_sz2 > HEATSHRINK_MAX_WINDOW_BITS) ||
+ (input_buffer_size == 0) ||
+ (lookahead_sz2 < HEATSHRINK_MIN_LOOKAHEAD_BITS) ||
+ (lookahead_sz2 >= window_sz2)) {
+ return NULL;
+ }
+ size_t buffers_sz = (1 << window_sz2) + input_buffer_size;
+ size_t sz = sizeof(heatshrink_decoder) + buffers_sz;
+ heatshrink_decoder *hsd = HEATSHRINK_MALLOC(sz);
+ if (hsd == NULL) { return NULL; }
+ hsd->input_buffer_size = input_buffer_size;
+ hsd->window_sz2 = window_sz2;
+ hsd->lookahead_sz2 = lookahead_sz2;
+ heatshrink_decoder_reset(hsd);
+ LOG("-- allocated decoder with buffer size of %zu (%zu + %u + %u)\n",
+ sz, sizeof(heatshrink_decoder), (1 << window_sz2), input_buffer_size);
+ return hsd;
+}
+
+void heatshrink_decoder_free(heatshrink_decoder *hsd) {
+ size_t buffers_sz = (1 << hsd->window_sz2) + hsd->input_buffer_size;
+ size_t sz = sizeof(heatshrink_decoder) + buffers_sz;
+ HEATSHRINK_FREE(hsd, sz);
+ (void)sz; /* may not be used by free */
+}
+#endif
+
+void heatshrink_decoder_reset(heatshrink_decoder *hsd) {
+ size_t buf_sz = 1 << HEATSHRINK_DECODER_WINDOW_BITS(hsd);
+ size_t input_sz = HEATSHRINK_DECODER_INPUT_BUFFER_SIZE(hsd);
+ memset(hsd->buffers, 0, buf_sz + input_sz);
+ hsd->state = HSDS_TAG_BIT;
+ hsd->input_size = 0;
+ hsd->input_index = 0;
+ hsd->bit_index = 0x00;
+ hsd->current_byte = 0x00;
+ hsd->output_count = 0;
+ hsd->output_index = 0;
+ hsd->head_index = 0;
+}
+
+/* Copy SIZE bytes into the decoder's input buffer, if it will fit. */
+HSD_sink_res heatshrink_decoder_sink(heatshrink_decoder *hsd,
+ uint8_t *in_buf, size_t size, size_t *input_size) {
+ if ((hsd == NULL) || (in_buf == NULL) || (input_size == NULL)) {
+ return HSDR_SINK_ERROR_NULL;
+ }
+
+ size_t rem = HEATSHRINK_DECODER_INPUT_BUFFER_SIZE(hsd) - hsd->input_size;
+ if (rem == 0) {
+ *input_size = 0;
+ return HSDR_SINK_FULL;
+ }
+
+ size = rem < size ? rem : size;
+ LOG("-- sinking %zd bytes\n", size);
+ /* copy into input buffer (at head of buffers) */
+ memcpy(&hsd->buffers[hsd->input_size], in_buf, size);
+ hsd->input_size += size;
+ *input_size = size;
+ return HSDR_SINK_OK;
+}
+
+
+/*****************
+ * Decompression *
+ *****************/
+
+#define BACKREF_COUNT_BITS(HSD) (HEATSHRINK_DECODER_LOOKAHEAD_BITS(HSD))
+#define BACKREF_INDEX_BITS(HSD) (HEATSHRINK_DECODER_WINDOW_BITS(HSD))
+
+// States
+static HSD_state st_tag_bit(heatshrink_decoder *hsd);
+static HSD_state st_yield_literal(heatshrink_decoder *hsd,
+ output_info *oi);
+static HSD_state st_backref_index_msb(heatshrink_decoder *hsd);
+static HSD_state st_backref_index_lsb(heatshrink_decoder *hsd);
+static HSD_state st_backref_count_msb(heatshrink_decoder *hsd);
+static HSD_state st_backref_count_lsb(heatshrink_decoder *hsd);
+static HSD_state st_yield_backref(heatshrink_decoder *hsd,
+ output_info *oi);
+
+HSD_poll_res heatshrink_decoder_poll(heatshrink_decoder *hsd,
+ uint8_t *out_buf, size_t out_buf_size, size_t *output_size) {
+ if ((hsd == NULL) || (out_buf == NULL) || (output_size == NULL)) {
+ return HSDR_POLL_ERROR_NULL;
+ }
+ *output_size = 0;
+
+ output_info oi;
+ oi.buf = out_buf;
+ oi.buf_size = out_buf_size;
+ oi.output_size = output_size;
+
+ while (1) {
+ LOG("-- poll, state is %d (%s), input_size %d\n",
+ hsd->state, state_names[hsd->state], hsd->input_size);
+ uint8_t in_state = hsd->state;
+ switch (in_state) {
+ case HSDS_TAG_BIT:
+ hsd->state = st_tag_bit(hsd);
+ break;
+ case HSDS_YIELD_LITERAL:
+ hsd->state = st_yield_literal(hsd, &oi);
+ break;
+ case HSDS_BACKREF_INDEX_MSB:
+ hsd->state = st_backref_index_msb(hsd);
+ break;
+ case HSDS_BACKREF_INDEX_LSB:
+ hsd->state = st_backref_index_lsb(hsd);
+ break;
+ case HSDS_BACKREF_COUNT_MSB:
+ hsd->state = st_backref_count_msb(hsd);
+ break;
+ case HSDS_BACKREF_COUNT_LSB:
+ hsd->state = st_backref_count_lsb(hsd);
+ break;
+ case HSDS_YIELD_BACKREF:
+ hsd->state = st_yield_backref(hsd, &oi);
+ break;
+ default:
+ return HSDR_POLL_ERROR_UNKNOWN;
+ }
+
+ /* If the current state cannot advance, check if input or output
+ * buffer are exhausted. */
+ if (hsd->state == in_state) {
+ if (*output_size == out_buf_size) { return HSDR_POLL_MORE; }
+ return HSDR_POLL_EMPTY;
+ }
+ }
+}
+
+static HSD_state st_tag_bit(heatshrink_decoder *hsd) {
+ uint32_t bits = get_bits(hsd, 1); // get tag bit
+ if (bits == NO_BITS) {
+ return HSDS_TAG_BIT;
+ } else if (bits) {
+ return HSDS_YIELD_LITERAL;
+ } else if (HEATSHRINK_DECODER_WINDOW_BITS(hsd) > 8) {
+ return HSDS_BACKREF_INDEX_MSB;
+ } else {
+ hsd->output_index = 0;
+ return HSDS_BACKREF_INDEX_LSB;
+ }
+}
+
+static HSD_state st_yield_literal(heatshrink_decoder *hsd,
+ output_info *oi) {
+ /* Emit a repeated section from the window buffer, and add it (again)
+ * to the window buffer. (Note that the repetition can include
+ * itself.)*/
+ if (*oi->output_size < oi->buf_size) {
+ uint16_t byte = get_bits(hsd, 8);
+ if (byte == NO_BITS) { return HSDS_YIELD_LITERAL; } /* out of input */
+ uint8_t *buf = &hsd->buffers[HEATSHRINK_DECODER_INPUT_BUFFER_SIZE(hsd)];
+ uint16_t mask = (1 << HEATSHRINK_DECODER_WINDOW_BITS(hsd)) - 1;
+ uint8_t c = byte & 0xFF;
+ LOG("-- emitting literal byte 0x%02x ('%c')\n", c, isprint(c) ? c : '.');
+ buf[hsd->head_index++ & mask] = c;
+ push_byte(hsd, oi, c);
+ return HSDS_TAG_BIT;
+ } else {
+ return HSDS_YIELD_LITERAL;
+ }
+}
+
+static HSD_state st_backref_index_msb(heatshrink_decoder *hsd) {
+ uint8_t bit_ct = BACKREF_INDEX_BITS(hsd);
+ ASSERT(bit_ct > 8);
+ uint16_t bits = get_bits(hsd, bit_ct - 8);
+ LOG("-- backref index (msb), got 0x%04x (+1)\n", bits);
+ if (bits == NO_BITS) { return HSDS_BACKREF_INDEX_MSB; }
+ hsd->output_index = bits << 8;
+ return HSDS_BACKREF_INDEX_LSB;
+}
+
+static HSD_state st_backref_index_lsb(heatshrink_decoder *hsd) {
+ uint8_t bit_ct = BACKREF_INDEX_BITS(hsd);
+ uint16_t bits = get_bits(hsd, bit_ct < 8 ? bit_ct : 8);
+ LOG("-- backref index (lsb), got 0x%04x (+1)\n", bits);
+ if (bits == NO_BITS) { return HSDS_BACKREF_INDEX_LSB; }
+ hsd->output_index |= bits;
+ hsd->output_index++;
+ uint8_t br_bit_ct = BACKREF_COUNT_BITS(hsd);
+ hsd->output_count = 0;
+ return (br_bit_ct > 8) ? HSDS_BACKREF_COUNT_MSB : HSDS_BACKREF_COUNT_LSB;
+}
+
+static HSD_state st_backref_count_msb(heatshrink_decoder *hsd) {
+ uint8_t br_bit_ct = BACKREF_COUNT_BITS(hsd);
+ ASSERT(br_bit_ct > 8);
+ uint16_t bits = get_bits(hsd, br_bit_ct - 8);
+ LOG("-- backref count (msb), got 0x%04x (+1)\n", bits);
+ if (bits == NO_BITS) { return HSDS_BACKREF_COUNT_MSB; }
+ hsd->output_count = bits << 8;
+ return HSDS_BACKREF_COUNT_LSB;
+}
+
+static HSD_state st_backref_count_lsb(heatshrink_decoder *hsd) {
+ uint8_t br_bit_ct = BACKREF_COUNT_BITS(hsd);
+ uint16_t bits = get_bits(hsd, br_bit_ct < 8 ? br_bit_ct : 8);
+ LOG("-- backref count (lsb), got 0x%04x (+1)\n", bits);
+ if (bits == NO_BITS) { return HSDS_BACKREF_COUNT_LSB; }
+ hsd->output_count |= bits;
+ hsd->output_count++;
+ return HSDS_YIELD_BACKREF;
+}
+
+static HSD_state st_yield_backref(heatshrink_decoder *hsd,
+ output_info *oi) {
+ size_t count = oi->buf_size - *oi->output_size;
+ if (count > 0) {
+ size_t i = 0;
+ if (hsd->output_count < count) count = hsd->output_count;
+ uint8_t *buf = &hsd->buffers[HEATSHRINK_DECODER_INPUT_BUFFER_SIZE(hsd)];
+ uint16_t mask = (1 << HEATSHRINK_DECODER_WINDOW_BITS(hsd)) - 1;
+ uint16_t neg_offset = hsd->output_index;
+ LOG("-- emitting %zu bytes from -%u bytes back\n", count, neg_offset);
+ ASSERT(neg_offset <= mask + 1);
+ ASSERT(count <= (size_t)(1 << BACKREF_COUNT_BITS(hsd)));
+
+ for (i=0; ihead_index - neg_offset) & mask];
+ push_byte(hsd, oi, c);
+ buf[hsd->head_index & mask] = c;
+ hsd->head_index++;
+ LOG(" -- ++ 0x%02x\n", c);
+ }
+ hsd->output_count -= count;
+ if (hsd->output_count == 0) { return HSDS_TAG_BIT; }
+ }
+ return HSDS_YIELD_BACKREF;
+}
+
+/* Get the next COUNT bits from the input buffer, saving incremental progress.
+ * Returns NO_BITS on end of input, or if more than 15 bits are requested. */
+static uint16_t get_bits(heatshrink_decoder *hsd, uint8_t count) {
+ uint16_t accumulator = 0;
+ int i = 0;
+ if (count > 15) { return NO_BITS; }
+ LOG("-- popping %u bit(s)\n", count);
+
+ /* If we aren't able to get COUNT bits, suspend immediately, because we
+ * don't track how many bits of COUNT we've accumulated before suspend. */
+ if (hsd->input_size == 0) {
+ if (hsd->bit_index < (1 << (count - 1))) { return NO_BITS; }
+ }
+
+ for (i = 0; i < count; i++) {
+ if (hsd->bit_index == 0x00) {
+ if (hsd->input_size == 0) {
+ LOG(" -- out of bits, suspending w/ accumulator of %u (0x%02x)\n",
+ accumulator, accumulator);
+ return NO_BITS;
+ }
+ hsd->current_byte = hsd->buffers[hsd->input_index++];
+ LOG(" -- pulled byte 0x%02x\n", hsd->current_byte);
+ if (hsd->input_index == hsd->input_size) {
+ hsd->input_index = 0; /* input is exhausted */
+ hsd->input_size = 0;
+ }
+ hsd->bit_index = 0x80;
+ }
+ accumulator <<= 1;
+ if (hsd->current_byte & hsd->bit_index) {
+ accumulator |= 0x01;
+ if (0) {
+ LOG(" -- got 1, accumulator 0x%04x, bit_index 0x%02x\n",
+ accumulator, hsd->bit_index);
+ }
+ } else {
+ if (0) {
+ LOG(" -- got 0, accumulator 0x%04x, bit_index 0x%02x\n",
+ accumulator, hsd->bit_index);
+ }
+ }
+ hsd->bit_index >>= 1;
+ }
+
+ if (count > 1) { LOG(" -- accumulated %08x\n", accumulator); }
+ return accumulator;
+}
+
+HSD_finish_res heatshrink_decoder_finish(heatshrink_decoder *hsd) {
+ if (hsd == NULL) { return HSDR_FINISH_ERROR_NULL; }
+ switch (hsd->state) {
+ case HSDS_TAG_BIT:
+ return hsd->input_size == 0 ? HSDR_FINISH_DONE : HSDR_FINISH_MORE;
+
+ /* If we want to finish with no input, but are in these states, it's
+ * because the 0-bit padding to the last byte looks like a backref
+ * marker bit followed by all 0s for index and count bits. */
+ case HSDS_BACKREF_INDEX_LSB:
+ case HSDS_BACKREF_INDEX_MSB:
+ case HSDS_BACKREF_COUNT_LSB:
+ case HSDS_BACKREF_COUNT_MSB:
+ return hsd->input_size == 0 ? HSDR_FINISH_DONE : HSDR_FINISH_MORE;
+
+ /* If the output stream is padded with 0xFFs (possibly due to being in
+ * flash memory), also explicitly check the input size rather than
+ * uselessly returning MORE but yielding 0 bytes when polling. */
+ case HSDS_YIELD_LITERAL:
+ return hsd->input_size == 0 ? HSDR_FINISH_DONE : HSDR_FINISH_MORE;
+
+ default:
+ return HSDR_FINISH_MORE;
+ }
+}
+
+static void push_byte(heatshrink_decoder *hsd, output_info *oi, uint8_t byte) {
+ LOG(" -- pushing byte: 0x%02x ('%c')\n", byte, isprint(byte) ? byte : '.');
+ oi->buf[(*oi->output_size)++] = byte;
+ (void)hsd;
+}
diff --git a/src/heatshrink/heatshrink_decoder.h b/src/heatshrink/heatshrink_decoder.h
new file mode 100644
index 00000000..bda83991
--- /dev/null
+++ b/src/heatshrink/heatshrink_decoder.h
@@ -0,0 +1,100 @@
+#ifndef HEATSHRINK_DECODER_H
+#define HEATSHRINK_DECODER_H
+
+#include
+#include
+#include "heatshrink_common.h"
+#include "heatshrink_config.h"
+
+typedef enum {
+ HSDR_SINK_OK, /* data sunk, ready to poll */
+ HSDR_SINK_FULL, /* out of space in internal buffer */
+ HSDR_SINK_ERROR_NULL=-1, /* NULL argument */
+} HSD_sink_res;
+
+typedef enum {
+ HSDR_POLL_EMPTY, /* input exhausted */
+ HSDR_POLL_MORE, /* more data remaining, call again w/ fresh output buffer */
+ HSDR_POLL_ERROR_NULL=-1, /* NULL arguments */
+ HSDR_POLL_ERROR_UNKNOWN=-2,
+} HSD_poll_res;
+
+typedef enum {
+ HSDR_FINISH_DONE, /* output is done */
+ HSDR_FINISH_MORE, /* more output remains */
+ HSDR_FINISH_ERROR_NULL=-1, /* NULL arguments */
+} HSD_finish_res;
+
+#if HEATSHRINK_DYNAMIC_ALLOC
+#define HEATSHRINK_DECODER_INPUT_BUFFER_SIZE(BUF) \
+ ((BUF)->input_buffer_size)
+#define HEATSHRINK_DECODER_WINDOW_BITS(BUF) \
+ ((BUF)->window_sz2)
+#define HEATSHRINK_DECODER_LOOKAHEAD_BITS(BUF) \
+ ((BUF)->lookahead_sz2)
+#else
+#define HEATSHRINK_DECODER_INPUT_BUFFER_SIZE(_) \
+ HEATSHRINK_STATIC_INPUT_BUFFER_SIZE
+#define HEATSHRINK_DECODER_WINDOW_BITS(_) \
+ (HEATSHRINK_STATIC_WINDOW_BITS)
+#define HEATSHRINK_DECODER_LOOKAHEAD_BITS(BUF) \
+ (HEATSHRINK_STATIC_LOOKAHEAD_BITS)
+#endif
+
+typedef struct {
+ uint16_t input_size; /* bytes in input buffer */
+ uint16_t input_index; /* offset to next unprocessed input byte */
+ uint16_t output_count; /* how many bytes to output */
+ uint16_t output_index; /* index for bytes to output */
+ uint16_t head_index; /* head of window buffer */
+ uint8_t state; /* current state machine node */
+ uint8_t current_byte; /* current byte of input */
+ uint8_t bit_index; /* current bit index */
+
+#if HEATSHRINK_DYNAMIC_ALLOC
+ /* Fields that are only used if dynamically allocated. */
+ uint8_t window_sz2; /* window buffer bits */
+ uint8_t lookahead_sz2; /* lookahead bits */
+ uint16_t input_buffer_size; /* input buffer size */
+
+ /* Input buffer, then expansion window buffer */
+ uint8_t buffers[];
+#else
+ /* Input buffer, then expansion window buffer */
+ uint8_t buffers[(1 << HEATSHRINK_DECODER_WINDOW_BITS(_))
+ + HEATSHRINK_DECODER_INPUT_BUFFER_SIZE(_)];
+#endif
+} heatshrink_decoder;
+
+#if HEATSHRINK_DYNAMIC_ALLOC
+/* Allocate a decoder with an input buffer of INPUT_BUFFER_SIZE bytes,
+ * an expansion buffer size of 2^WINDOW_SZ2, and a lookahead
+ * size of 2^lookahead_sz2. (The window buffer and lookahead sizes
+ * must match the settings used when the data was compressed.)
+ * Returns NULL on error. */
+heatshrink_decoder *heatshrink_decoder_alloc(uint16_t input_buffer_size,
+ uint8_t expansion_buffer_sz2, uint8_t lookahead_sz2);
+
+/* Free a decoder. */
+void heatshrink_decoder_free(heatshrink_decoder *hsd);
+#endif
+
+/* Reset a decoder. */
+void heatshrink_decoder_reset(heatshrink_decoder *hsd);
+
+/* Sink at most SIZE bytes from IN_BUF into the decoder. *INPUT_SIZE is set to
+ * indicate how many bytes were actually sunk (in case a buffer was filled). */
+HSD_sink_res heatshrink_decoder_sink(heatshrink_decoder *hsd,
+ uint8_t *in_buf, size_t size, size_t *input_size);
+
+/* Poll for output from the decoder, copying at most OUT_BUF_SIZE bytes into
+ * OUT_BUF (setting *OUTPUT_SIZE to the actual amount copied). */
+HSD_poll_res heatshrink_decoder_poll(heatshrink_decoder *hsd,
+ uint8_t *out_buf, size_t out_buf_size, size_t *output_size);
+
+/* Notify the dencoder that the input stream is finished.
+ * If the return value is HSDR_FINISH_MORE, there is still more output, so
+ * call heatshrink_decoder_poll and repeat. */
+HSD_finish_res heatshrink_decoder_finish(heatshrink_decoder *hsd);
+
+#endif
diff --git a/src/parser.c b/src/parser.c
index 9a114bd8..551277ea 100644
--- a/src/parser.c
+++ b/src/parser.c
@@ -65,6 +65,7 @@
#include "oceans_s1.h"
#include "divesoft_freedom.h"
#include "halcyon_symbios.h"
+#include "suunto_nautic.h"
#include "context-private.h"
#include "parser-private.h"
@@ -208,6 +209,9 @@ dc_parser_new_internal (dc_parser_t **out, dc_context_t *context, const unsigned
case DC_FAMILY_HALCYON_SYMBIOS:
rc = halcyon_symbios_parser_create (&parser, context, data, size);
break;
+ case DC_FAMILY_SUUNTO_NAUTIC:
+ rc = suunto_nautic_parser_create (&parser, context, data, size);
+ break;
default:
return DC_STATUS_INVALIDARGS;
}
diff --git a/src/suunto_nautic.c b/src/suunto_nautic.c
new file mode 100644
index 00000000..ffb65ab0
--- /dev/null
+++ b/src/suunto_nautic.c
@@ -0,0 +1,1071 @@
+/*
+ * libdivecomputer
+ *
+ * Copyright (C) 2026 Jef Driesen
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+ * MA 02110-1301 USA
+ */
+
+#include
+#include
+#include
+
+#include "suunto_nautic.h"
+#include "context-private.h"
+#include "device-private.h"
+#include "platform.h"
+#include "checksum.h"
+#include "array.h"
+#include "hdlc.h"
+#include "heatshrink/heatshrink_decoder.h"
+
+// See suunto_nautic.h for a description of the transport and format.
+
+#define RPC_OP_GET 0x0A
+#define RPC_OP_STREAM_FETCH1 0x0B
+#define RPC_OP_FETCH 0x0D
+#define RPC_OP_STREAM_FETCH2 0x10
+#define RPC_OP_DATA 0x05
+
+#define RPC_HEADER_SIZE 10 // magic(1) + opcode(1) + sublen(2) + seq(2) + 0x01 + 0x80 + 0x00 + pathlen(1)
+#define RPC_CRC_SIZE 4
+
+// Offset of the 3-byte session handle inside an ACK (0x02) or DATA (0x05)
+// frame: magic(1) + opcode(1) + sublen(2) + msgid(2).
+#define RPC_HANDLE_OFFSET 6
+// Offset of the 16-bit LE HTTP-like status inside a DATA (0x05) frame:
+// magic(1) + opcode(1) + sublen(2) + msgid(2) + handle(3) + flags(3).
+#define RPC_STATUS_OFFSET 12
+#define RPC_STATUS_OK 200
+#define RPC_STATUS_CONTINUE 100 // more pages follow (paginated fetch)
+
+#define MAX_PATH 240
+#define MAX_PACKET 512
+
+// Dive IDs are UNIX timestamps. /Logbook/Entries embeds them as 4-aligned
+// little-endian uint32 values in a small SBEM payload among handle/flag/
+// count/CRC fields; filtering to a plausible timestamp window (2017 .. 2036)
+// isolates them. Must scan 4-aligned -- the IDs are packed adjacently, so an
+// unaligned read straddling two can invent a phantom dive.
+#define DIVE_ID_MIN 1500000000u
+#define DIVE_ID_MAX 2100000000u
+
+// Each entry stores start then end timestamp adjacently; an end is always
+// within a day of its start. Used to pair (start, end) so a dive's end isn't
+// listed as a second dive.
+#define DIVE_ENTRY_MAX_PAIR_GAP 86400u
+
+// Number of PMT-style chunks to accept before giving up. This is a safety
+// cap, not a protocol constant — the real termination condition (how the
+// watch signals "no more chunks") is unknown, so we stop on the first read
+// timeout instead.
+#define MAX_CHUNKS 4096
+
+// Safety cap on paginated-fetch pages (a Summary is a handful of pages).
+#define MAX_PAGES 64
+
+// The Suunto "MDS" chunk header wrapping each compressed block: 28 bytes,
+// with the true payload size as a u16 LE at offset 20 and the compressed
+// payload starting at offset 28.
+#define MDS_HEADER_SIZE 28
+#define MDS_CHUNK_SIZE_OFFSET 20
+
+// Heatshrink (LZSS) parameters used by the Nautic/Ocean's MDS stream.
+#define HEATSHRINK_WINDOW_SZ2 7
+#define HEATSHRINK_LOOKAHEAD_SZ2 5
+#define HEATSHRINK_INPUT_BUFFER_SIZE 256
+
+static const unsigned char SBEM_MAGIC[8] = {'S','B','E','M','0','1','0','3'};
+
+typedef struct suunto_nautic_device_t {
+ dc_device_t base;
+ dc_iostream_t *iostream; // HDLC-framed
+ unsigned int sequence;
+ // The dive ID (a UNIX timestamp, see suunto_nautic_device_foreach) of
+ // the most recently downloaded dive, little-endian, as returned via
+ // dc_dive_callback_t's fingerprint parameter. All-zero means "no
+ // fingerprint set" (a real dive ID is never 0 -- that would be a
+ // 1970 timestamp), matching every other driver's convention.
+ unsigned char fingerprint[4];
+} suunto_nautic_device_t;
+
+static dc_status_t suunto_nautic_device_set_fingerprint (dc_device_t *abstract, const unsigned char data[], unsigned int size);
+static dc_status_t suunto_nautic_device_foreach (dc_device_t *abstract, dc_dive_callback_t callback, void *userdata);
+static dc_status_t suunto_nautic_device_close (dc_device_t *abstract);
+
+static const dc_device_vtable_t suunto_nautic_device_vtable = {
+ sizeof(suunto_nautic_device_t),
+ DC_FAMILY_SUUNTO_NAUTIC,
+ suunto_nautic_device_set_fingerprint, /* set_fingerprint */
+ NULL, /* read */
+ NULL, /* write */
+ NULL, /* dump */
+ suunto_nautic_device_foreach, /* foreach */
+ NULL, /* timesync */
+ suunto_nautic_device_close, /* close */
+};
+
+static dc_status_t
+suunto_nautic_device_set_fingerprint (dc_device_t *abstract, const unsigned char data[], unsigned int size)
+{
+ suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract;
+
+ if (size && size != sizeof (device->fingerprint))
+ return DC_STATUS_INVALIDARGS;
+
+ if (size)
+ memcpy (device->fingerprint, data, sizeof (device->fingerprint));
+ else
+ memset (device->fingerprint, 0, sizeof (device->fingerprint));
+
+ return DC_STATUS_SUCCESS;
+}
+
+/*
+ * The "EVA" handshake is the Whiteboard protocol's Hello message (message
+ * type 0x12). The payload carries a SuuntoSerial identity, a fixed
+ * protocol-version block, a capability-flags byte and a trailing CRC32.
+ * The identity has no cryptographic tie to a specific phone, and the watch
+ * has only been confirmed to answer the captured template, so it is sent
+ * verbatim.
+ */
+static const unsigned char suunto_nautic_eva_handshake[] = {
+ 0xA5, 0x12, 0x20, 0x00, 0x00, 0x00, 0x09, 0x09, 0x20, 0x16, 0x45, 0x56,
+ 0x41, 0x10, 0x04, 0x41, 0x10, 0x0C, 0x00, 0x00, 0x00, 0x04, 0x01, 0x02,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x63, 0x1B, 0x47, 0x1B
+};
+
+#define EVA_HANDSHAKE_SIZE (sizeof (suunto_nautic_eva_handshake))
+
+/*
+ * Stream-fetch trigger tails, captured verbatim. The sequence-number field
+ * (bytes 4-5) is the watch's session handle for this transfer plus 1
+ * (FETCH1) or plus 2 (FETCH2), read from the ACK to the preceding GET
+ * request; see suunto_nautic_device_download(). The remaining tail bytes
+ * are replayed literally.
+ */
+static const unsigned char suunto_nautic_fetch1_tail[] = {
+ 0x00, 0x24, 0x12, 0x01, 0x80, 0x00
+};
+static const unsigned char suunto_nautic_fetch2_tail[] = {
+ 0x00, 0x24, 0x0E, 0x01, 0x80, 0x00, 0x00
+};
+
+// Build a generic path-addressed GET request for an arbitrary endpoint.
+static dc_status_t
+suunto_nautic_build_get (unsigned char packet[], unsigned int size, unsigned int *out_len, unsigned int seq, const char *path)
+{
+ size_t pathlen = strlen (path);
+ if (pathlen == 0 || pathlen > MAX_PATH)
+ return DC_STATUS_INVALIDARGS;
+
+ unsigned int len = RPC_HEADER_SIZE + (unsigned int) pathlen + RPC_CRC_SIZE;
+ if (len > size)
+ return DC_STATUS_INVALIDARGS;
+
+ unsigned int sublen = (unsigned int) pathlen + 4;
+
+ packet[0] = 0xA5;
+ packet[1] = RPC_OP_GET;
+ array_uint16_le_set (packet + 2, (unsigned short) sublen);
+ array_uint16_le_set (packet + 4, (unsigned short) seq);
+ packet[6] = 0x01;
+ packet[7] = 0x80;
+ packet[8] = 0x00;
+ packet[9] = (unsigned char) pathlen;
+ memcpy (packet + 10, path, pathlen);
+
+ unsigned int crc = checksum_crc32r (packet, RPC_HEADER_SIZE + (unsigned int) pathlen);
+ array_uint32_le_set (packet + RPC_HEADER_SIZE + (unsigned int) pathlen, crc);
+
+ *out_len = len;
+ return DC_STATUS_SUCCESS;
+}
+
+// Build a stream-fetch trigger frame. Only the opcode and sequence number
+// are derived; the tail is a literal replay (see the caveats above the
+// suunto_nautic_fetch{1,2}_tail tables).
+static dc_status_t
+suunto_nautic_build_stream_fetch (unsigned char packet[], unsigned int size, unsigned int *out_len,
+ unsigned int seq, unsigned char opcode, const unsigned char tail[], unsigned int tail_size)
+{
+ unsigned int len = RPC_HEADER_SIZE - 4 + tail_size + RPC_CRC_SIZE; // magic+opcode+sublen+seq (6) + tail + crc
+ if (len > size)
+ return DC_STATUS_INVALIDARGS;
+
+ packet[0] = 0xA5;
+ packet[1] = opcode;
+ array_uint16_le_set (packet + 2, (unsigned short) tail_size);
+ array_uint16_le_set (packet + 4, (unsigned short) seq);
+ memcpy (packet + 6, tail, tail_size);
+
+ unsigned int crc = checksum_crc32r (packet, 6 + tail_size);
+ array_uint32_le_set (packet + 6 + tail_size, crc);
+
+ *out_len = len;
+ return DC_STATUS_SUCCESS;
+}
+
+// Build the "short" fetch (opcode 0x0D) the official app uses to read a
+// small whole resource in one shot, e.g. /Logbook/Entries. Payload is
+// [seq:2 LE][handle:3][01 80 00 00] -- the trailing 01 80 00 00 is the
+// no-range form. NOT the ranged fetch used for large paginated resources
+// (Summary/Data), whose payload ends 01 80 00 01 06 00 [offset:4]; sending
+// that ranged form to /Logbook/Entries makes the watch reject it with a
+// 400 Bad Request.
+static dc_status_t
+suunto_nautic_build_short_fetch (unsigned char packet[], unsigned int size, unsigned int *out_len,
+ unsigned int seq, const unsigned char handle[3])
+{
+ static const unsigned char tail[] = { 0x01, 0x80, 0x00, 0x00 };
+ unsigned int payload = 3 + (unsigned int) sizeof (tail); // handle(3) + tail
+ unsigned int len = 4 + 2 + payload + RPC_CRC_SIZE; // magic+opcode+sublen(4) + seq(2) + payload + crc
+ if (len > size)
+ return DC_STATUS_INVALIDARGS;
+
+ packet[0] = 0xA5;
+ packet[1] = RPC_OP_FETCH;
+ // sublen counts seq(2)+payload minus 2, i.e. payload itself.
+ array_uint16_le_set (packet + 2, (unsigned short) payload);
+ array_uint16_le_set (packet + 4, (unsigned short) seq);
+ memcpy (packet + 6, handle, 3);
+ memcpy (packet + 9, tail, sizeof (tail));
+
+ unsigned int crc = checksum_crc32r (packet, 6 + payload);
+ array_uint32_le_set (packet + 6 + payload, crc);
+
+ *out_len = len;
+ return DC_STATUS_SUCCESS;
+}
+
+// Build a paginated fetch (opcode 0x0D) for a resource the watch returns
+// across multiple pages, e.g. /Logbook/byId//Summary. Payload is
+// [seq:2 LE][handle:3][01 80 00 01 06 00][offset:4 LE] -- the ranged form.
+// The watch answers each page with HTTP status 100 (more pages) or 200
+// (last page).
+static dc_status_t
+suunto_nautic_build_paginated_fetch (unsigned char packet[], unsigned int size, unsigned int *out_len,
+ unsigned int seq, const unsigned char handle[3], unsigned int offset)
+{
+ static const unsigned char flags[] = { 0x01, 0x80, 0x00, 0x01, 0x06, 0x00 };
+ unsigned int payload = 3 + (unsigned int) sizeof (flags) + 4; // handle(3) + flags(6) + offset(4)
+ unsigned int len = 4 + 2 + payload + RPC_CRC_SIZE;
+ if (len > size)
+ return DC_STATUS_INVALIDARGS;
+
+ packet[0] = 0xA5;
+ packet[1] = RPC_OP_FETCH;
+ array_uint16_le_set (packet + 2, (unsigned short) payload);
+ array_uint16_le_set (packet + 4, (unsigned short) seq);
+ memcpy (packet + 6, handle, 3);
+ memcpy (packet + 9, flags, sizeof (flags));
+ array_uint32_le_set (packet + 9 + (unsigned int) sizeof (flags), offset);
+
+ unsigned int crc = checksum_crc32r (packet, 6 + payload);
+ array_uint32_le_set (packet + 6 + payload, crc);
+
+ *out_len = len;
+ return DC_STATUS_SUCCESS;
+}
+
+static dc_status_t
+suunto_nautic_transfer (suunto_nautic_device_t *device, const unsigned char req[], unsigned int rsize, dc_buffer_t *response)
+{
+ dc_status_t status = DC_STATUS_SUCCESS;
+ dc_device_t *abstract = (dc_device_t *) device;
+
+ if (device_is_cancelled (abstract))
+ return DC_STATUS_CANCELLED;
+
+ status = dc_iostream_write (device->iostream, req, rsize, NULL);
+ if (status != DC_STATUS_SUCCESS) {
+ ERROR (abstract->context, "Failed to send the RPC request.");
+ return status;
+ }
+
+ if (response) {
+ unsigned char packet[MAX_PACKET] = {0};
+ size_t len = 0;
+ status = dc_iostream_read (device->iostream, packet, sizeof (packet), &len);
+ if (status != DC_STATUS_SUCCESS) {
+ ERROR (abstract->context, "Failed to receive the RPC response.");
+ return status;
+ }
+
+ HEXDUMP (abstract->context, DC_LOGLEVEL_DEBUG, "RPC RSP", packet, len);
+
+ dc_buffer_clear (response);
+ if (!dc_buffer_append (response, packet, len)) {
+ ERROR (abstract->context, "Failed to allocate memory.");
+ return DC_STATUS_NOMEMORY;
+ }
+ }
+
+ return DC_STATUS_SUCCESS;
+}
+
+dc_status_t
+suunto_nautic_device_open (dc_device_t **out, dc_context_t *context, dc_iostream_t *iostream)
+{
+ dc_status_t status = DC_STATUS_SUCCESS;
+ suunto_nautic_device_t *device = NULL;
+
+ if (out == NULL)
+ return DC_STATUS_INVALIDARGS;
+
+ device = (suunto_nautic_device_t *) dc_device_allocate (context, &suunto_nautic_device_vtable);
+ if (device == NULL) {
+ ERROR (context, "Failed to allocate memory.");
+ return DC_STATUS_NOMEMORY;
+ }
+
+ device->sequence = 1;
+ memset (device->fingerprint, 0, sizeof (device->fingerprint));
+
+ status = dc_hdlc_open (&device->iostream, context, iostream, 244, 244);
+ if (status != DC_STATUS_SUCCESS) {
+ ERROR (context, "Failed to open the HDLC layer.");
+ goto error_free;
+ }
+
+ status = dc_iostream_set_timeout (device->iostream, 5000);
+ if (status != DC_STATUS_SUCCESS) {
+ ERROR (context, "Failed to set the timeout.");
+ goto error_close;
+ }
+
+ 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.
+ 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);
+ if (status != DC_STATUS_SUCCESS) {
+ ERROR (context, "Failed to send the EVA handshake.");
+ goto error_close;
+ }
+
+ 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;
+ }
+
+ HEXDUMP (context, DC_LOGLEVEL_DEBUG, "EVA RSP", handshake_rsp, handshake_len);
+
+ *out = (dc_device_t *) device;
+
+ return DC_STATUS_SUCCESS;
+
+error_close:
+ dc_iostream_close (device->iostream);
+error_free:
+ dc_device_deallocate ((dc_device_t *) device);
+ return status;
+}
+
+static dc_status_t
+suunto_nautic_device_close (dc_device_t *abstract)
+{
+ suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract;
+
+ return dc_iostream_close (device->iostream);
+}
+
+static dc_status_t
+suunto_nautic_device_request (dc_device_t *abstract, const char *path, dc_buffer_t *response)
+{
+ if (abstract == NULL || abstract->vtable->type != DC_FAMILY_SUUNTO_NAUTIC || path == NULL)
+ return DC_STATUS_INVALIDARGS;
+
+ suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract;
+
+ unsigned char packet[RPC_HEADER_SIZE + MAX_PATH + RPC_CRC_SIZE];
+ unsigned int len = 0;
+ dc_status_t status = suunto_nautic_build_get (packet, sizeof (packet), &len, device->sequence, path);
+ if (status != DC_STATUS_SUCCESS)
+ return status;
+ device->sequence++;
+
+ return suunto_nautic_transfer (device, packet, len, response);
+}
+
+// Decompress a Heatshrink (LZSS) stream, per the parameters documented
+// above. Verified byte-for-byte against a reference implementation using
+// real captured data (see suunto_nautic.h).
+static dc_status_t
+suunto_nautic_heatshrink_decompress (dc_context_t *context, const unsigned char *input, size_t input_size, dc_buffer_t *output)
+{
+ dc_status_t status = DC_STATUS_SUCCESS;
+ unsigned char outbuf[HEATSHRINK_INPUT_BUFFER_SIZE];
+
+ heatshrink_decoder *hsd = heatshrink_decoder_alloc (HEATSHRINK_INPUT_BUFFER_SIZE, HEATSHRINK_WINDOW_SZ2, HEATSHRINK_LOOKAHEAD_SZ2);
+ if (hsd == NULL) {
+ ERROR (context, "Failed to allocate the heatshrink decoder.");
+ return DC_STATUS_NOMEMORY;
+ }
+
+ dc_buffer_clear (output);
+
+ size_t sunk_total = 0;
+ while (sunk_total < input_size) {
+ size_t sunk = 0;
+ HSD_sink_res sres = heatshrink_decoder_sink (hsd, (uint8_t *) input + sunk_total, input_size - sunk_total, &sunk);
+ if (sres < 0) {
+ ERROR (context, "Heatshrink sink error (%d).", sres);
+ status = DC_STATUS_DATAFORMAT;
+ goto done;
+ }
+ sunk_total += sunk;
+
+ HSD_poll_res pres;
+ do {
+ size_t polled = 0;
+ pres = heatshrink_decoder_poll (hsd, outbuf, sizeof (outbuf), &polled);
+ if (pres < 0) {
+ ERROR (context, "Heatshrink poll error (%d).", pres);
+ status = DC_STATUS_DATAFORMAT;
+ goto done;
+ }
+ if (polled && !dc_buffer_append (output, outbuf, polled)) {
+ ERROR (context, "Failed to allocate memory.");
+ status = DC_STATUS_NOMEMORY;
+ goto done;
+ }
+ } while (pres == HSDR_POLL_MORE);
+ }
+
+ HSD_finish_res fres = heatshrink_decoder_finish (hsd);
+ while (fres == HSDR_FINISH_MORE) {
+ HSD_poll_res pres;
+ do {
+ size_t polled = 0;
+ pres = heatshrink_decoder_poll (hsd, outbuf, sizeof (outbuf), &polled);
+ if (pres < 0) {
+ ERROR (context, "Heatshrink poll error (%d).", pres);
+ status = DC_STATUS_DATAFORMAT;
+ goto done;
+ }
+ if (polled && !dc_buffer_append (output, outbuf, polled)) {
+ ERROR (context, "Failed to allocate memory.");
+ status = DC_STATUS_NOMEMORY;
+ goto done;
+ }
+ } while (pres == HSDR_POLL_MORE);
+ fres = heatshrink_decoder_finish (hsd);
+ }
+
+done:
+ heatshrink_decoder_free (hsd);
+ return status;
+}
+
+// Performs the GET -> ACK(watch magic) -> FETCH1 -> FETCH2 -> stream-collect
+// sequence used to pull a large paginated resource (dive data). Returns the
+// raw, MDS-chunk-stripped, still-Heatshrink-compressed bytes. Small listing
+// endpoints use suunto_nautic_device_short_fetch() instead.
+static dc_status_t
+suunto_nautic_device_stream_fetch (dc_device_t *abstract, const char *path, dc_buffer_t *raw)
+{
+ suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract;
+ dc_status_t status = DC_STATUS_SUCCESS;
+
+ // 1. Request the resource. The watch's ACK carries a "Watch Magic"
+ // session id (little-endian UInt32 at offset 5) that authorizes this
+ // transfer; the two stream-fetch triggers below use Watch_Magic+1/+2.
+ dc_buffer_t *ack = dc_buffer_new (0);
+ if (ack == NULL)
+ return DC_STATUS_NOMEMORY;
+
+ status = suunto_nautic_device_request (abstract, path, ack);
+ if (status != DC_STATUS_SUCCESS) {
+ dc_buffer_free (ack);
+ ERROR (abstract->context, "Failed to request %s.", path);
+ return status;
+ }
+
+ const unsigned char *ack_data = dc_buffer_get_data (ack);
+ size_t ack_size = dc_buffer_get_size (ack);
+ if (ack_size < 9) {
+ dc_buffer_free (ack);
+ ERROR (abstract->context, "ACK response too short to contain the watch magic (" DC_PRINTF_SIZE ").", ack_size);
+ return DC_STATUS_DATAFORMAT;
+ }
+ unsigned int watch_magic = array_uint32_le (ack_data + 5);
+ dc_buffer_free (ack);
+
+ // 2. Trigger the stream using Watch_Magic+1/+2.
+ unsigned char fetch[32];
+ unsigned int fetch_len = 0;
+
+ status = suunto_nautic_build_stream_fetch (fetch, sizeof (fetch), &fetch_len, watch_magic + 1,
+ RPC_OP_STREAM_FETCH1, suunto_nautic_fetch1_tail, sizeof (suunto_nautic_fetch1_tail));
+ if (status != DC_STATUS_SUCCESS)
+ return status;
+
+ status = dc_iostream_write (device->iostream, fetch, fetch_len, NULL);
+ if (status != DC_STATUS_SUCCESS) {
+ ERROR (abstract->context, "Failed to send the first stream-fetch trigger.");
+ return status;
+ }
+
+ status = suunto_nautic_build_stream_fetch (fetch, sizeof (fetch), &fetch_len, watch_magic + 2,
+ RPC_OP_STREAM_FETCH2, suunto_nautic_fetch2_tail, sizeof (suunto_nautic_fetch2_tail));
+ if (status != DC_STATUS_SUCCESS)
+ return status;
+
+ status = dc_iostream_write (device->iostream, fetch, fetch_len, NULL);
+ if (status != DC_STATUS_SUCCESS) {
+ ERROR (abstract->context, "Failed to send the second stream-fetch trigger.");
+ return status;
+ }
+
+ // 3. Capture the MDS chunk frames (opcode 0x01) and pull out each
+ // one's true sub-payload: the MDS header is 28 bytes, and the payload
+ // size is a little-endian u16 at offset 20-21 (see the MDS_HEADER_SIZE
+ // comment above). For a compressed endpoint, the concatenation of
+ // these sub-payloads across all chunks is one continuous Heatshrink
+ // stream — chunk boundaries are purely a BLE/transport artifact, not
+ // boundaries in the compressed data.
+ //
+ // The watch is not ACKed per chunk: once FETCH2 is sent it streams the
+ // entire response continuously, and the host buffers until a 2.0s
+ // silence timeout. An RX opcode 0x09 frame (observed ending the stream)
+ // is also honoured as an early stop, but the 2.0s timeout is the
+ // primary mechanism.
+ status = dc_iostream_set_timeout (device->iostream, 2000);
+ if (status != DC_STATUS_SUCCESS) {
+ ERROR (abstract->context, "Failed to set the stream timeout.");
+ return status;
+ }
+
+ for (unsigned int i = 0; i < MAX_CHUNKS; i++) {
+ unsigned char packet[MAX_PACKET] = {0};
+ size_t len = 0;
+ status = dc_iostream_read (device->iostream, packet, sizeof (packet), &len);
+ if (status != DC_STATUS_SUCCESS) {
+ if (status == DC_STATUS_TIMEOUT)
+ break;
+ ERROR (abstract->context, "Failed to receive a stream chunk.");
+ return status;
+ }
+
+ if (len == 0)
+ break;
+
+ if (len >= 2 && packet[0] == 0xA5 && packet[1] == 0x09)
+ break;
+
+ if (len >= 2 && packet[0] == 0xA5 && packet[1] == 0x01) {
+ if (len < MDS_HEADER_SIZE) {
+ WARNING (abstract->context, "MDS chunk shorter than the header (" DC_PRINTF_SIZE ").", len);
+ continue;
+ }
+
+ unsigned int chunk_size = array_uint16_le (packet + MDS_CHUNK_SIZE_OFFSET);
+ if (MDS_HEADER_SIZE + chunk_size > len) {
+ WARNING (abstract->context, "MDS chunk size (%u) exceeds the frame (" DC_PRINTF_SIZE ").", chunk_size, len);
+ continue;
+ }
+
+ if (!dc_buffer_append (raw, packet + MDS_HEADER_SIZE, chunk_size)) {
+ ERROR (abstract->context, "Failed to allocate memory.");
+ return DC_STATUS_NOMEMORY;
+ }
+ }
+ }
+
+ return DC_STATUS_SUCCESS;
+}
+
+// Fetch a resource the watch paginates (e.g. /Logbook/byId//Summary):
+// GET -> ACK(handle) -> repeated ranged 0x0D fetch, looping while the page
+// status is 100 (more pages) until 200 (last page), stripping the 10-byte
+// REST sub-header from each page and accumulating the raw (uncompressed)
+// SBEM bytes into `response`. The per-page REST sub-header (at packet+4) is
+// [msgid:2][handle:3][flags:3][status:2 LE].
+static dc_status_t
+suunto_nautic_device_paginated_fetch (dc_device_t *abstract, const char *path, dc_buffer_t *response)
+{
+ suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract;
+ dc_status_t status = DC_STATUS_SUCCESS;
+
+ dc_buffer_t *ack = dc_buffer_new (0);
+ if (ack == NULL)
+ return DC_STATUS_NOMEMORY;
+
+ status = suunto_nautic_device_request (abstract, path, ack);
+ if (status != DC_STATUS_SUCCESS) {
+ dc_buffer_free (ack);
+ ERROR (abstract->context, "Failed to request %s.", path);
+ return status;
+ }
+
+ const unsigned char *ack_data = dc_buffer_get_data (ack);
+ size_t ack_size = dc_buffer_get_size (ack);
+ if (ack_size < RPC_HANDLE_OFFSET + 3) {
+ dc_buffer_free (ack);
+ ERROR (abstract->context, "ACK too short for a handle (" DC_PRINTF_SIZE ").", ack_size);
+ return DC_STATUS_DATAFORMAT;
+ }
+ unsigned char handle[3];
+ memcpy (handle, ack_data + RPC_HANDLE_OFFSET, sizeof (handle));
+ dc_buffer_free (ack);
+
+ dc_buffer_clear (response);
+ unsigned int offset = 0;
+ const unsigned int header = 4 + 10; // A5 05 sublen(2) + 10-byte REST sub-header
+
+ for (unsigned int page = 0; page < MAX_PAGES; page++) {
+ unsigned char fetch[32];
+ unsigned int fetch_len = 0;
+ status = suunto_nautic_build_paginated_fetch (fetch, sizeof (fetch), &fetch_len,
+ device->sequence, handle, offset);
+ if (status != DC_STATUS_SUCCESS)
+ return status;
+ device->sequence++;
+
+ status = dc_iostream_write (device->iostream, fetch, fetch_len, NULL);
+ if (status != DC_STATUS_SUCCESS) {
+ ERROR (abstract->context, "Failed to send paginated fetch (offset=%u) for %s.", offset, path);
+ return status;
+ }
+
+ // Read the page, skipping unsolicited/interleaved frames: the watch
+ // multiplexes other resources (device info, analytics, a re-sent Hello)
+ // on the same link, so accept only a DATA (0x05) frame whose handle
+ // matches this fetch. Otherwise we'd splice a foreign frame into the
+ // paginated stream.
+ unsigned char packet[MAX_PACKET] = {0};
+ size_t len = 0;
+ unsigned int skips = 0;
+ const unsigned int max_skips = 8;
+ for (;;) {
+ status = dc_iostream_read (device->iostream, packet, sizeof (packet), &len);
+ if (status != DC_STATUS_SUCCESS) {
+ ERROR (abstract->context, "Failed to receive page %u for %s.", page, path);
+ return status;
+ }
+ HEXDUMP (abstract->context, DC_LOGLEVEL_DEBUG, "PFETCH RSP", packet, len);
+ if (len >= RPC_HANDLE_OFFSET + 3 && packet[0] == 0xA5 && packet[1] == RPC_OP_DATA &&
+ memcmp (packet + RPC_HANDLE_OFFSET, handle, sizeof (handle)) == 0)
+ break;
+ if (++skips >= max_skips) {
+ ERROR (abstract->context, "Unexpected frame for %s page %u (" DC_PRINTF_SIZE " bytes).", path, page, len);
+ return DC_STATUS_DATAFORMAT;
+ }
+ WARNING (abstract->context, "Skipping interleaved frame while paging %s (op 0x%02x).",
+ path, len >= 2 ? packet[1] : 0);
+ }
+
+ unsigned int frame_status = array_uint16_le (packet + RPC_STATUS_OFFSET);
+ if (frame_status != RPC_STATUS_OK && frame_status != RPC_STATUS_CONTINUE) {
+ ERROR (abstract->context, "Watch returned status %u for %s page %u.", frame_status, path, page);
+ return DC_STATUS_PROTOCOL;
+ }
+
+ if (len > header) {
+ if (!dc_buffer_append (response, packet + header, len - header)) {
+ ERROR (abstract->context, "Failed to allocate memory.");
+ return DC_STATUS_NOMEMORY;
+ }
+ offset += (unsigned int) (len - header);
+ }
+
+ if (frame_status == RPC_STATUS_OK) {
+ DEBUG (abstract->context, "Paginated fetch done: %u page(s), " DC_PRINTF_SIZE " bytes for %s.",
+ page + 1, dc_buffer_get_size (response), path);
+ return DC_STATUS_SUCCESS;
+ }
+ }
+
+ WARNING (abstract->context, "Paginated fetch hit the page limit for %s -- data may be truncated.", path);
+ return DC_STATUS_SUCCESS;
+}
+
+// Fetch a small whole resource (e.g. /Logbook/Entries) via the official
+// app's GET -> ACK(handle) -> SHORT-FETCH(0x0D) -> DATA(0x05) flow. The
+// listing endpoints don't answer the 0x0B/0x10 stream-fetch triggers, only
+// this no-range 0x0D form. `response` receives the raw DATA-frame content
+// (everything after the A5 05 sublen header). Assumes the resource fits in
+// one DATA frame, which holds for a normal logbook.
+// GET -> ACK(handle) -> 0x0D short fetch -> read one frame. Returns the RAW
+// frame bytes (the whole A5.. packet) in `frame`, with NO opcode validation.
+// suunto_nautic_device_short_fetch() validates and extracts the content.
+static dc_status_t
+suunto_nautic_short_fetch_frame (dc_device_t *abstract, const char *path, dc_buffer_t *frame, int skip_non_data)
+{
+ suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract;
+ dc_status_t status = DC_STATUS_SUCCESS;
+
+ // 1. GET the resource; the ACK carries the 3-byte session handle.
+ dc_buffer_t *ack = dc_buffer_new (0);
+ if (ack == NULL)
+ return DC_STATUS_NOMEMORY;
+
+ status = suunto_nautic_device_request (abstract, path, ack);
+ if (status != DC_STATUS_SUCCESS) {
+ dc_buffer_free (ack);
+ ERROR (abstract->context, "Failed to request %s.", path);
+ return status;
+ }
+
+ const unsigned char *ack_data = dc_buffer_get_data (ack);
+ size_t ack_size = dc_buffer_get_size (ack);
+ if (ack_size < RPC_HANDLE_OFFSET + 3) {
+ dc_buffer_free (ack);
+ ERROR (abstract->context, "ACK too short for a handle (" DC_PRINTF_SIZE ").", ack_size);
+ return DC_STATUS_DATAFORMAT;
+ }
+ unsigned char handle[3];
+ memcpy (handle, ack_data + RPC_HANDLE_OFFSET, sizeof (handle));
+ dc_buffer_free (ack);
+
+ // 2. SHORT fetch (no range header) reads the whole small resource.
+ unsigned char fetch[32];
+ unsigned int fetch_len = 0;
+ status = suunto_nautic_build_short_fetch (fetch, sizeof (fetch), &fetch_len, device->sequence, handle);
+ if (status != DC_STATUS_SUCCESS)
+ return status;
+ device->sequence++;
+
+ status = dc_iostream_write (device->iostream, fetch, fetch_len, NULL);
+ if (status != DC_STATUS_SUCCESS) {
+ ERROR (abstract->context, "Failed to send the short fetch for %s.", path);
+ return status;
+ }
+
+ // 3. Read the response frame. The watch occasionally injects an
+ // unsolicited Hello frame (op 0x12/0x13) mid-session, and a single blind
+ // read can grab that instead of the DATA frame -- an intermittent
+ // DC_STATUS_DATAFORMAT. When skip_non_data is set, skip frames that aren't
+ // a DATA (0x05) frame until the real one arrives (bounded). The raw
+ // diagnostic passes 0 and returns the very first frame, whatever it is.
+ unsigned char packet[MAX_PACKET] = {0};
+ size_t len = 0;
+ unsigned int attempts = 0;
+ const unsigned int max_attempts = 8;
+ do {
+ status = dc_iostream_read (device->iostream, packet, sizeof (packet), &len);
+ if (status != DC_STATUS_SUCCESS) {
+ ERROR (abstract->context, "Failed to receive the data for %s.", path);
+ return status;
+ }
+ HEXDUMP (abstract->context, DC_LOGLEVEL_DEBUG, "FETCH RSP", packet, len);
+ if (!skip_non_data)
+ break;
+ // Accept only a DATA (0x05) frame whose 3-byte handle matches the one
+ // this fetch was issued against. The watch interleaves unsolicited
+ // frames (a re-sent Hello, or an analytics/event stream on a different
+ // handle), and a blind read would grab those -- surfacing as an
+ // intermittent DC_STATUS_DATAFORMAT or, worse, the wrong resource's
+ // bytes. Skip anything that isn't our DATA frame and read again.
+ if (len >= RPC_HANDLE_OFFSET + 3 && packet[0] == 0xA5 && packet[1] == RPC_OP_DATA &&
+ memcmp (packet + RPC_HANDLE_OFFSET, handle, sizeof (handle)) == 0)
+ break;
+ WARNING (abstract->context, "Skipping unexpected frame while fetching %s (op 0x%02x handle %s).",
+ path, len >= 2 ? packet[1] : 0,
+ len >= RPC_HANDLE_OFFSET + 3 ? "mismatch" : "short");
+ } while (++attempts < max_attempts);
+
+ dc_buffer_clear (frame);
+ if (!dc_buffer_append (frame, packet, len)) {
+ ERROR (abstract->context, "Failed to allocate memory.");
+ return DC_STATUS_NOMEMORY;
+ }
+
+ return DC_STATUS_SUCCESS;
+}
+
+static dc_status_t
+suunto_nautic_device_short_fetch (dc_device_t *abstract, const char *path, dc_buffer_t *response)
+{
+ dc_buffer_t *frame = dc_buffer_new (0);
+ if (frame == NULL)
+ return DC_STATUS_NOMEMORY;
+
+ dc_status_t status = suunto_nautic_short_fetch_frame (abstract, path, frame, 1);
+ if (status != DC_STATUS_SUCCESS) {
+ dc_buffer_free (frame);
+ return status;
+ }
+
+ const unsigned char *packet = dc_buffer_get_data (frame);
+ size_t len = dc_buffer_get_size (frame);
+
+ if (len < RPC_STATUS_OFFSET + 2 || packet[0] != 0xA5 || packet[1] != RPC_OP_DATA) {
+ ERROR (abstract->context, "Unexpected data frame for %s (" DC_PRINTF_SIZE " bytes).", path, len);
+ dc_buffer_free (frame);
+ return DC_STATUS_DATAFORMAT;
+ }
+
+ // 200 = the whole resource fits in this one frame. 100 = a paginated first
+ // page: the watch has more entries than one page holds and would serve the
+ // rest via a continuation request. The continuation is not followed yet, so
+ // accept the page we got (the most recent dives) rather than failing;
+ // following it would return the older dives beyond this page.
+ unsigned int frame_status = array_uint16_le (packet + RPC_STATUS_OFFSET);
+ if (frame_status != RPC_STATUS_OK && frame_status != RPC_STATUS_CONTINUE) {
+ ERROR (abstract->context, "Watch returned status %u for %s (200/100 expected).", frame_status, path);
+ dc_buffer_free (frame);
+ return DC_STATUS_PROTOCOL;
+ }
+
+ // Return the frame content (everything after A5 05 sublen).
+ dc_buffer_clear (response);
+ int ok = dc_buffer_append (response, packet + 4, len - 4);
+ dc_buffer_free (frame);
+ if (!ok) {
+ ERROR (abstract->context, "Failed to allocate memory.");
+ return DC_STATUS_NOMEMORY;
+ }
+
+ return DC_STATUS_SUCCESS;
+}
+
+static dc_status_t
+suunto_nautic_device_download_summary (dc_device_t *abstract, const char *logbook_id, dc_buffer_t *summary)
+{
+ if (abstract == NULL || abstract->vtable->type != DC_FAMILY_SUUNTO_NAUTIC || logbook_id == NULL || summary == NULL)
+ return DC_STATUS_INVALIDARGS;
+
+ char path[128];
+ int n = snprintf (path, sizeof (path), "/Logbook/byId/%s/Summary", logbook_id);
+ if (n < 0 || (size_t) n >= sizeof (path))
+ return DC_STATUS_INVALIDARGS;
+
+ // /Summary uses the paginated 0x0D fetch and is NOT compressed -- the
+ // result is raw SBEM0103 (the caller locates the signature and reads
+ // its fields, e.g. gradient factors and gas mix).
+ return suunto_nautic_device_paginated_fetch (abstract, path, summary);
+}
+
+static dc_status_t
+suunto_nautic_device_download (dc_device_t *abstract, const char *logbook_id, dc_buffer_t *raw)
+{
+ if (abstract == NULL || abstract->vtable->type != DC_FAMILY_SUUNTO_NAUTIC || logbook_id == NULL || raw == NULL)
+ return DC_STATUS_INVALIDARGS;
+
+ dc_status_t status = DC_STATUS_SUCCESS;
+
+ char path[128];
+ int n = snprintf (path, sizeof (path), "/Logbook/byId/%s/Data", logbook_id);
+ if (n < 0 || (size_t) n >= sizeof (path))
+ return DC_STATUS_INVALIDARGS;
+
+ dc_buffer_t *compressed = dc_buffer_new (0);
+ if (compressed == NULL)
+ return DC_STATUS_NOMEMORY;
+
+ status = suunto_nautic_device_stream_fetch (abstract, path, compressed);
+ if (status != DC_STATUS_SUCCESS) {
+ dc_buffer_free (compressed);
+ return status;
+ }
+
+ DEBUG (abstract->context, "Captured " DC_PRINTF_SIZE " compressed bytes for logbook entry %s.",
+ dc_buffer_get_size (compressed), logbook_id);
+
+ // Decompress and verify the SBEM0103 magic.
+ status = suunto_nautic_heatshrink_decompress (abstract->context,
+ dc_buffer_get_data (compressed), dc_buffer_get_size (compressed), raw);
+ dc_buffer_free (compressed);
+ if (status != DC_STATUS_SUCCESS) {
+ ERROR (abstract->context, "Failed to decompress the logbook entry.");
+ return status;
+ }
+
+ if (dc_buffer_get_size (raw) < sizeof (SBEM_MAGIC) ||
+ memcmp (dc_buffer_get_data (raw), SBEM_MAGIC, sizeof (SBEM_MAGIC)) != 0) {
+ ERROR (abstract->context, "Unexpected magic in the decompressed data.");
+ return DC_STATUS_DATAFORMAT;
+ }
+
+ DEBUG (abstract->context, "Decompressed " DC_PRINTF_SIZE " bytes for logbook entry %s.",
+ dc_buffer_get_size (raw), logbook_id);
+
+ // Append the /Summary SBEM (gradient factors, gas mix) after the
+ // profile, so the parser can expose them via DC_FIELD_DECOMODEL /
+ // DC_FIELD_GASMIX -- these aren't in the profile stream. Best-effort:
+ // the profile alone is still a valid dive if this fails.
+ dc_buffer_t *summary = dc_buffer_new (0);
+ if (summary != NULL) {
+ if (suunto_nautic_device_download_summary (abstract, logbook_id, summary) == DC_STATUS_SUCCESS &&
+ dc_buffer_get_size (summary) > 0) {
+ if (!dc_buffer_append (raw, dc_buffer_get_data (summary), dc_buffer_get_size (summary)))
+ WARNING (abstract->context, "Failed to append the Summary; GF/gas will be unavailable.");
+ } else {
+ WARNING (abstract->context, "Failed to fetch the Summary for %s; GF/gas will be unavailable.", logbook_id);
+ }
+ dc_buffer_free (summary);
+ }
+
+ return DC_STATUS_SUCCESS;
+}
+
+// Extract dive-start ids from a /Logbook/Entries response, newest-first. Each
+// entry is a start timestamp immediately followed by its end timestamp
+// (end > start, within a day), both 4-aligned little-endian uint32s in the
+// dive-ID window; a lone in-range value with no paired end is a header/misc
+// field (the response's own "current time"), not a dive. Writes up to max_ids
+// ids and returns the count.
+static unsigned int
+suunto_nautic_extract_entry_ids (const unsigned char *data, size_t size,
+ unsigned int *ids, unsigned int max_ids)
+{
+ unsigned int count = 0;
+ for (size_t i = 0; i + 8 <= size && count < max_ids; i += 4) {
+ unsigned int v = array_uint32_le (data + i);
+ if (v < DIVE_ID_MIN || v > DIVE_ID_MAX)
+ continue;
+ unsigned int next = array_uint32_le (data + i + 4);
+ if (next >= DIVE_ID_MIN && next <= DIVE_ID_MAX &&
+ next > v && next - v <= DIVE_ENTRY_MAX_PAIR_GAP) {
+ ids[count++] = v; // start of a (start, end) pair
+ i += 4; // skip the paired end timestamp
+ }
+ }
+ // Sort descending (newest first); insertion sort is fine at logbook scale.
+ for (unsigned int a = 1; a < count; a++) {
+ unsigned int key = ids[a];
+ int b = (int) a - 1;
+ while (b >= 0 && ids[b] < key) { ids[b + 1] = ids[b]; b--; }
+ ids[b + 1] = key;
+ }
+ return count;
+}
+
+static dc_status_t
+suunto_nautic_device_foreach (dc_device_t *abstract, dc_dive_callback_t callback, void *userdata)
+{
+ dc_status_t status = DC_STATUS_SUCCESS;
+ suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract;
+
+ dc_event_progress_t progress = EVENT_PROGRESS_INITIALIZER;
+ progress.maximum = 2;
+ device_event_emit (abstract, DC_EVENT_PROGRESS, &progress);
+
+ // Connectivity/auth check. Any path works here; /System/Mode is a
+ // fixed, id-less endpoint so it works identically on every unit.
+ dc_buffer_t *mode = dc_buffer_new (0);
+ if (mode == NULL)
+ return DC_STATUS_NOMEMORY;
+
+ status = suunto_nautic_device_request (abstract, "/System/Mode", mode);
+ if (status != DC_STATUS_SUCCESS) {
+ dc_buffer_free (mode);
+ ERROR (abstract->context, "Failed to reach /System/Mode. The EVA handshake or RPC "
+ "framing may need updating for this device (see suunto_nautic.h).");
+ return status;
+ }
+
+ dc_event_vendor_t vendor;
+ vendor.data = dc_buffer_get_data (mode);
+ vendor.size = (unsigned int) dc_buffer_get_size (mode);
+ device_event_emit (abstract, DC_EVENT_VENDOR, &vendor);
+ dc_buffer_free (mode);
+
+ progress.current = 1;
+ device_event_emit (abstract, DC_EVENT_PROGRESS, &progress);
+
+ // /Logbook/Entries returns a small SBEM payload embedding each dive's
+ // LogId (a UNIX timestamp) as a 4-aligned little-endian uint32 among
+ // handle/flag/count/CRC fields; a timestamp-window filter (DIVE_ID_MIN/
+ // MAX) isolates the IDs. Uses the short 0x0D fetch -- the watch rejects
+ // the ranged stream-fetch (used for dive data) here.
+ dc_buffer_t *entries = dc_buffer_new (0);
+ if (entries == NULL)
+ return DC_STATUS_NOMEMORY;
+
+ status = suunto_nautic_device_short_fetch (abstract, "/Logbook/Entries", entries);
+ if (status != DC_STATUS_SUCCESS) {
+ dc_buffer_free (entries);
+ ERROR (abstract->context, "Failed to fetch /Logbook/Entries.");
+ return status;
+ }
+
+ const unsigned char *entries_data = dc_buffer_get_data (entries);
+ size_t entries_size = dc_buffer_get_size (entries);
+
+ size_t max_ids = entries_size / 4 + 1; // at most one id per 4 bytes
+ unsigned int *ids = (unsigned int *) malloc (max_ids * sizeof (unsigned int));
+ if (ids == NULL) {
+ dc_buffer_free (entries);
+ return DC_STATUS_NOMEMORY;
+ }
+ unsigned int count = suunto_nautic_extract_entry_ids (entries_data, entries_size,
+ ids, (unsigned int) max_ids);
+ dc_buffer_free (entries);
+
+ progress.maximum = (count + 1) * 2;
+ device_event_emit (abstract, DC_EVENT_PROGRESS, &progress);
+
+ dc_buffer_t *raw = dc_buffer_new (0);
+ if (raw == NULL) {
+ free (ids);
+ return DC_STATUS_NOMEMORY;
+ }
+
+ for (unsigned int i = 0; i < count; i++) {
+ unsigned char fingerprint[4] = {
+ (unsigned char) (ids[i] & 0xFF),
+ (unsigned char) ((ids[i] >> 8) & 0xFF),
+ (unsigned char) ((ids[i] >> 16) & 0xFF),
+ (unsigned char) ((ids[i] >> 24) & 0xFF),
+ };
+
+ // Walking newest-first, so the first fingerprint match means
+ // everything from here on was already downloaded in a
+ // previous session.
+ if (memcmp (fingerprint, device->fingerprint, sizeof (fingerprint)) == 0)
+ break;
+
+ char logbook_id[16];
+ int n = snprintf (logbook_id, sizeof (logbook_id), "%u", ids[i]);
+ if (n < 0 || (size_t) n >= sizeof (logbook_id))
+ continue;
+
+ dc_buffer_clear (raw);
+ status = suunto_nautic_device_download (abstract, logbook_id, raw);
+ if (status != DC_STATUS_SUCCESS) {
+ // A logbook can contain empty/aborted entries (a zero-length
+ // session is listed in /Logbook/Entries but downloads to no
+ // profile data and fails the SBEM magic check). Skip with a
+ // warning rather than aborting the whole enumeration.
+ WARNING (abstract->context, "Skipping logbook entry %s (download failed, likely an empty/aborted dive).", logbook_id);
+ status = DC_STATUS_SUCCESS;
+ continue;
+ }
+
+ progress.current += 2;
+ device_event_emit (abstract, DC_EVENT_PROGRESS, &progress);
+
+ if (callback && !callback (dc_buffer_get_data (raw), (unsigned int) dc_buffer_get_size (raw),
+ fingerprint, sizeof (fingerprint), userdata))
+ break;
+ }
+
+ dc_buffer_free (raw);
+ free (ids);
+
+ return DC_STATUS_SUCCESS;
+}
diff --git a/src/suunto_nautic.h b/src/suunto_nautic.h
new file mode 100644
index 00000000..3e8d21b0
--- /dev/null
+++ b/src/suunto_nautic.h
@@ -0,0 +1,105 @@
+/*
+ * libdivecomputer
+ *
+ * Copyright (C) 2026 Jef Driesen
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+ * MA 02110-1301 USA
+ */
+
+/*
+ * Suunto Nautic / Ocean ("Vaasa" generation) BLE dive computers.
+ *
+ * Transport and framing:
+ * - BLE, with HDLC framing (flag 0x7E, escape 0x7D / XOR 0x20; no
+ * separate checksum at this layer).
+ * - Path-addressed GET requests use the RPC frame envelope:
+ * 0xA5, opcode, sublen(u16 LE), seq(u16 LE), 0x01, 0x80, 0x00,
+ * pathlen(u8), path bytes..., crc32(u32 LE)
+ * where crc32 is the reflected CRC-32 (checksum_crc32r) of every
+ * preceding byte. A single monotonic per-connection sequence counter
+ * is used for all requests.
+ *
+ * Dive download:
+ * - GET /Data (opcode 0x0A) -> ack -> two stream-fetch triggers
+ * -> chunk stream (opcode 0x01, repeated). The chunk stream is
+ * unacknowledged and continuous; the host buffers until a 2.0s
+ * silence timeout.
+ * - Each 0x01 frame carries a 28-byte MDS header: payload size is a
+ * u16 LE at offset 20, payload starts at offset 28.
+ * - Payloads are Heatshrink-compressed (LZSS variant, see
+ * src/heatshrink/; window_sz2 = 7, lookahead_sz2 = 5). The decoded
+ * stream is an SBEM0103 container.
+ *
+ * SBEM0103 container:
+ * - A numeric Type-Length-Value stream: [id: 1 byte][length: 1 byte]
+ * [value: length bytes]; length == 255 means an extended 4-byte LE
+ * length follows before the value. Unknown chunk IDs are skipped.
+ * - Decoded chunks (suunto_nautic_parser.c): 0x12 (1Hz absolute
+ * pressure / temperature), 0x16 (depth, cylinder pressures, NDL,
+ * time-to-surface), 0x0B (GPS), 0x17 (surface pressure ->
+ * DC_FIELD_ATMOSPHERIC), plus dive events and high-rate IMU.
+ * - Series libdivecomputer has no sample type for (battery, GPS
+ * accuracy, 9-axis IMU, dive-route features) are emitted through
+ * DC_SAMPLE_VENDOR tagged SAMPLE_VENDOR_SUUNTO_NAUTIC, each record
+ * led by a VENDOR_KIND_* byte.
+ *
+ * Enumeration:
+ * - suunto_nautic_device_foreach() fetches /Logbook/Entries (via the
+ * short 0x0D fetch; the listing endpoint rejects the stream fetch
+ * used for dive data) and extracts each dive's LogId, which is a
+ * UNIX timestamp, from the SBEM payload. Dives are downloaded
+ * newest-first, stopping at the first id matching the fingerprint.
+ *
+ * Datetime:
+ * - The stream carries no wall-clock timestamp except in GPS fixes
+ * (chunk 0x0B), which hold an absolute UTC in milliseconds, so
+ * dc_parser_get_datetime() derives start = gps_utc - gps_rel_time.
+ * A dive with no surface GPS fix has no absolute clock in the
+ * stream; datetime is unsupported for it. The LogId (the dive id) is
+ * itself that timestamp and can serve as a fallback.
+ *
+ * Resynchronisation:
+ * - Heatshrink decompression can leave localized artifacts (runs of a
+ * repeated byte) that a naive TLV walk would misread as a chunk
+ * header and desync on. suunto_nautic_sbem_next() validates every
+ * fixed-length chunk id (0x08=6, 0x0B=20, 0x0E=6, 0x14=7, 0x16=195,
+ * 0x17=14 bytes) against its expected length and, on a mismatch,
+ * rescans forward one byte at a time instead of trusting the header.
+ */
+
+#ifndef SUUNTO_NAUTIC_H
+#define SUUNTO_NAUTIC_H
+
+#include
+#include
+#include
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif /* __cplusplus */
+
+dc_status_t
+suunto_nautic_device_open (dc_device_t **device, dc_context_t *context, dc_iostream_t *iostream);
+
+dc_status_t
+suunto_nautic_parser_create (dc_parser_t **parser, dc_context_t *context, const unsigned char data[], size_t size);
+
+#ifdef __cplusplus
+}
+#endif /* __cplusplus */
+#endif /* SUUNTO_NAUTIC_H */
diff --git a/src/suunto_nautic_parser.c b/src/suunto_nautic_parser.c
new file mode 100644
index 00000000..30cc0d29
--- /dev/null
+++ b/src/suunto_nautic_parser.c
@@ -0,0 +1,893 @@
+/*
+ * libdivecomputer
+ *
+ * Copyright (C) 2026 Jef Driesen
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+ * MA 02110-1301 USA
+ */
+
+/*
+ * Parses the SBEM0103 TLV stream produced by
+ * suunto_nautic_device_download() after MDS-chunk extraction and
+ * Heatshrink decompression (see suunto_nautic.c/.h). The format is:
+ *
+ * [chunk id: 1 byte][length: 1 byte][value: length bytes]
+ * length == 255 means an extended 4-byte little-endian length follows
+ * 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 /
+ * gyroscope dumps for client-side dead reckoning, emitted through
+ * DC_SAMPLE_VENDOR. Unknown chunk ids are skipped, so extending the
+ * decoder is additive.
+ *
+ * Sample time is delta-encoded: every chunk except the timeline base
+ * (0x01) begins with a signed int16 LE millisecond delta. The dive start
+ * time (dc_parser_get_datetime()) is reconstructed from the only absolute
+ * clock in the stream: each GPS fix (chunk 0x0B) carries an absolute UTC
+ * in milliseconds, so start = gps_utc - gps_relative_time. A dive with no
+ * surface GPS fix has no absolute clock, so datetime is unsupported for it.
+ */
+
+#include
+#include
+#include
+
+#include "suunto_nautic.h"
+#include "context-private.h"
+#include "parser-private.h"
+#include "array.h"
+
+#define SBEM_MAGIC_SIZE 8
+
+#define CHUNK_TIMELINE_BASE 0x01
+#define CHUNK_ACTIVITY 0x08
+#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 %]
+// 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
+// extended-status watches (Nautic), 0x22 on the 141-byte ones (Nautic S /
+// Ocean). Both are matched by (id in {0x22,0x23} AND payload >= 24); see the
+// IMU handler. Keying on the id alone silently dropped IMU on 141-byte watches.
+#define CHUNK_IMU 0x23
+#define CHUNK_IMU_ALT 0x22
+// DiveRouteFeatures: 5x uint16 (16-byte payload), id 0x24 on 195-byte watches
+// and 0x23 (small) on 141-byte ones. These are input features to the app's
+// dive-route tracking algorithm, not the resulting X/Y/Z track (which the
+// device doesn't store; the app dead-reckons it from the raw IMU above). The
+// meaning of the individual features is unknown; they are carried through.
+#define CHUNK_DIVEROUTE_FEATURES 0x24
+
+// libdivecomputer has no dedicated battery/GPS-accuracy/IMU sample types, so
+// these are delivered through the generic DC_SAMPLE_VENDOR channel tagged
+// SAMPLE_VENDOR_SUUNTO_NAUTIC. Byte 0 of every vendor record is one of these
+// kinds; the rest is a canonical little-endian payload (see each handler).
+#define VENDOR_KIND_BATTERY 1 // [voltage_mv:u16][charge_permille:u16]
+#define VENDOR_KIND_GPS_ACCURACY 2 // [ehpe_m:u16][evpe_m:u16]
+#define VENDOR_KIND_IMU 3 // [ax,ay,az,gx,gy,gz,mx,my,mz:int16]
+#define VENDOR_KIND_DIVEROUTE_FEATURES 4 // [f0,f1,f2,f3,f4:uint16] DiveRouteFeatures (algo inputs; not the X/Y/Z track)
+#define VENDOR_KIND_GF 5 // [gf99:int16][gf_surface:int16][gf_leading:int16] (%)
+#define CHUNK_PROFILE_1HZ 0x12
+#define CHUNK_EXTENDED_STATUS 0x16 // VARIABLE length (195 on Ocean/Nautic, 141
+ // on Nautic S and other firmware); fields are
+ // read by offset, each guarded by chunk.size.
+ // Deliberately not in the fixed-length table.
+#define CHUNK_SURFACE_PRESSURE 0x17
+// Dive-event groups: the chunk id IS the event subgroup, each record is
+// [timeDelta:2 LE][Type:1][Active:1] (Active 1=begin/onset, 0=end/cleared).
+// Type indexes the subgroup's own enum (see the descriptor schema).
+#define CHUNK_EVENT_ALARM 0x18
+#define CHUNK_EVENT_WARNING 0x19
+#define CHUNK_EVENT_NOTIFY 0x1A
+#define CHUNK_EVENT_STATE 0x1B
+#define CHUNK_DIVE_STATE 0x1C // [timeDelta:2][state:1]; 0=Idling,1=Diving,2=Recovering
+#define CHUNK_DIVE_STATUS 0x1E // [timeDelta:2][active:1]; the DiveActive flag
+#define CHUNK_OOAM 0x1D // [timeDelta:2][type:1]; one-shot dive-end reason (Ooam.Type)
+#define CHUNK_GAS_SWITCH 0x1F // [timeDelta:2][gasnumber:int16 LE]
+
+// DiveState values (CHUNK_DIVE_STATE payload).
+#define DIVE_STATE_IDLING 0
+#define DIVE_STATE_DIVING 1
+#define DIVE_STATE_RECOVERING 2
+
+#define MAX_TANKS 8
+#define MAX_GASMIXES 4
+
+// Field offsets within the /Summary SBEM0103 section (relative to its
+// "SBEM0103" signature), confirmed against real hardware. The dive profile
+// (/Data) carries samples but not these; the driver appends the /Summary
+// SBEM after the profile so this parser can expose them the standard way.
+// GF low is at 0x35 and GF high at 0x33 (uint16 LE, %). This ordering yields
+// valid low <= high on two devices (Nautic 35/75, Nautic S 40/85); the reverse
+// gives low > high.
+#define SUMMARY_GF_LOW 0x35 // uint16 LE, %
+#define SUMMARY_GF_HIGH 0x33 // uint16 LE, %
+#define SUMMARY_GAS_BASE 0xC7 // first gas; 4 bytes each: id, O2%, He%, type
+
+typedef struct suunto_nautic_tank_t {
+ unsigned int used;
+ double beginpressure; // bar
+ double endpressure; // bar
+} suunto_nautic_tank_t;
+
+typedef struct suunto_nautic_parser_t {
+ dc_parser_t base;
+ unsigned int cached;
+ unsigned int divetime; // seconds
+ double maxdepth; // meters
+ double avgdepth; // meters
+ unsigned int have_temperature;
+ double temperature_minimum;
+ double temperature_maximum;
+ unsigned int ntanks;
+ suunto_nautic_tank_t tank[MAX_TANKS];
+ unsigned int have_location;
+ dc_location_t location;
+ unsigned int have_atmospheric;
+ double atmospheric; // bar
+ unsigned int have_datetime;
+ dc_ticks_t datetime; // dive start, UNIX seconds
+ // From the /Summary SBEM section appended after the profile, if present.
+ unsigned int ngasmixes;
+ dc_gasmix_t gasmix[MAX_GASMIXES];
+ unsigned int have_decomodel;
+ dc_decomodel_t decomodel;
+} suunto_nautic_parser_t;
+
+typedef struct sbem_chunk_t {
+ unsigned int id;
+ const unsigned char *data;
+ unsigned int size;
+} sbem_chunk_t;
+
+static dc_status_t suunto_nautic_parser_get_datetime (dc_parser_t *abstract, dc_datetime_t *datetime);
+static dc_status_t suunto_nautic_parser_get_field (dc_parser_t *abstract, dc_field_type_t type, unsigned int flags, void *value);
+static dc_status_t suunto_nautic_parser_samples_foreach (dc_parser_t *abstract, dc_sample_callback_t callback, void *userdata);
+
+static const dc_parser_vtable_t suunto_nautic_parser_vtable = {
+ sizeof(suunto_nautic_parser_t),
+ DC_FAMILY_SUUNTO_NAUTIC,
+ NULL, /* set_clock */
+ NULL, /* set_atmospheric */
+ NULL, /* set_density */
+ suunto_nautic_parser_get_datetime, /* datetime */
+ suunto_nautic_parser_get_field,
+ suunto_nautic_parser_samples_foreach,
+ NULL, /* destroy */
+};
+
+dc_status_t
+suunto_nautic_parser_create (dc_parser_t **out, dc_context_t *context, const unsigned char data[], size_t size)
+{
+ suunto_nautic_parser_t *parser = NULL;
+
+ if (out == NULL)
+ return DC_STATUS_INVALIDARGS;
+
+ parser = (suunto_nautic_parser_t *) dc_parser_allocate (context, &suunto_nautic_parser_vtable, data, size);
+ if (parser == NULL) {
+ ERROR (context, "Failed to allocate memory.");
+ return DC_STATUS_NOMEMORY;
+ }
+
+ parser->cached = 0;
+
+ *out = (dc_parser_t *) parser;
+
+ return DC_STATUS_SUCCESS;
+}
+
+// Chunk IDs whose payload length is fixed and has been confirmed
+// against real captured data. Heatshrink decompression can leave
+// localized artifacts in the stream (e.g. runs of a single repeated
+// byte), which a strict linear TLV walk would otherwise misread as a
+// chunk header -- permanently desyncing every chunk after it. Any
+// candidate header naming one of these IDs is only accepted if its
+// length byte matches; otherwise it is a "ghost chunk" and the parser
+// resynchronizes by scanning forward one byte at a time.
+static int
+suunto_nautic_sbem_fixed_length (unsigned int id)
+{
+ switch (id) {
+ case CHUNK_ACTIVITY: return 6;
+ case CHUNK_GPS: return 20;
+ case CHUNK_GPS_ACCURACY: return 6;
+ case CHUNK_BATTERY: return 7;
+ case CHUNK_SURFACE_PRESSURE: return 14;
+ default: return -1; // unknown or variable length
+ }
+}
+
+// Advance to the next TLV chunk starting at *offset. Returns 0 (and
+// leaves *offset unchanged) once the buffer is exhausted.
+static int
+suunto_nautic_sbem_next (const unsigned char data[], unsigned int size, unsigned int *offset, sbem_chunk_t *chunk)
+{
+ unsigned int pos = *offset;
+
+ while (pos + 2 <= size) {
+ unsigned int id = data[pos];
+ unsigned int length = data[pos + 1];
+ unsigned int header = 2;
+
+ if (length == 255) {
+ if (pos + 6 > size) {
+ pos++;
+ continue;
+ }
+ length = array_uint32_le (data + pos + 2);
+ header = 6;
+ }
+
+ int fixed = suunto_nautic_sbem_fixed_length (id);
+ if (fixed >= 0 && (unsigned int) fixed != length) {
+ // Ghost chunk: a real chunk with this id never has
+ // this length. Resynchronize.
+ pos++;
+ continue;
+ }
+
+ if (pos + header + length > size) {
+ pos++;
+ continue;
+ }
+
+ chunk->id = id;
+ chunk->data = data + pos + header;
+ chunk->size = length;
+
+ *offset = pos + header + length;
+
+ return 1;
+ }
+
+ return 0;
+}
+
+// The driver appends the (uncompressed) /Summary SBEM after the profile,
+// so the combined buffer holds two "SBEM0103" sections. Return the offset
+// of the second one (the /Summary), or `size` when there's only the
+// profile. Bounds the profile chunk walk and locates the /Summary fields.
+static size_t
+suunto_nautic_find_summary (const unsigned char *data, size_t size)
+{
+ if (size < SBEM_MAGIC_SIZE)
+ return size;
+ for (size_t i = SBEM_MAGIC_SIZE; i + SBEM_MAGIC_SIZE <= size; i++) {
+ if (memcmp (data + i, "SBEM0103", SBEM_MAGIC_SIZE) == 0)
+ return i;
+ }
+ return size;
+}
+
+// Parse gradient factors and gas mixes from the /Summary section, whose
+// "SBEM0103" signature is at `sbem` (length `size`). Offsets are relative
+// to that signature (confirmed on real hardware). Gases are validated by
+// plausibility (O2 in 1..100, He in 0..100-O2) and counted until the first
+// implausible slot, since unused slots hold unrelated bytes.
+static void
+suunto_nautic_parse_summary (suunto_nautic_parser_t *parser, const unsigned char *sbem, size_t size)
+{
+ if (size >= SUMMARY_GF_LOW + 2) { // GF_LOW (0x35) is the higher of the two offsets
+ unsigned int low = array_uint16_le (sbem + SUMMARY_GF_LOW);
+ unsigned int high = array_uint16_le (sbem + SUMMARY_GF_HIGH);
+ parser->decomodel.type = DC_DECOMODEL_BUHLMANN;
+ parser->decomodel.conservatism = 0;
+ parser->decomodel.params.gf.low = low;
+ parser->decomodel.params.gf.high = high;
+ parser->have_decomodel = 1;
+ }
+
+ for (unsigned int i = 0; i < MAX_GASMIXES; i++) {
+ size_t base = SUMMARY_GAS_BASE + (size_t) i * 4;
+ if (base + 3 > size)
+ break;
+ unsigned int o2 = sbem[base + 1];
+ unsigned int he = sbem[base + 2];
+ if (o2 < 1 || o2 > 100 || he > 100 || o2 + he > 100)
+ break; // unused/implausible slot -- stop
+ parser->gasmix[parser->ngasmixes].oxygen = o2 / 100.0;
+ parser->gasmix[parser->ngasmixes].helium = he / 100.0;
+ parser->gasmix[parser->ngasmixes].nitrogen = 1.0 - (o2 + he) / 100.0;
+ parser->gasmix[parser->ngasmixes].usage = DC_USAGE_NONE;
+ parser->ngasmixes++;
+ }
+}
+
+// Map a Suunto dive-event (subgroup = chunk id, plus the subgroup's Type
+// enum) to the closest libdivecomputer sample-event type. Suunto's set is
+// richer than dc_sample_event_t, so unmapped subtypes fall back to a
+// generic marker; the raw subgroup+type is still available via the
+// descriptor for anyone needing the exact Suunto label.
+static unsigned int
+suunto_nautic_map_event (unsigned int chunk_id, unsigned int type)
+{
+ switch (chunk_id) {
+ case CHUNK_EVENT_ALARM:
+ switch (type) {
+ case 1: case 2: return SAMPLE_EVENT_PO2; // PO2 Low/High
+ case 3: return SAMPLE_EVENT_AIRTIME; // Tank Pressure
+ case 5: return SAMPLE_EVENT_ASCENT; // Ascent Speed
+ case 10: return SAMPLE_EVENT_CEILING; // Deco Stop Broken
+ case 12: return SAMPLE_EVENT_DEEPSTOP; // Deep Stop Broken
+ case 13: return SAMPLE_EVENT_SAFETYSTOP_MANDATORY;// Safety Stop Broken
+ default: return SAMPLE_EVENT_VIOLATION;
+ }
+ case CHUNK_EVENT_WARNING:
+ switch (type) {
+ case 28: return SAMPLE_EVENT_AIRTIME; // User Tank Pressure
+ default: return SAMPLE_EVENT_VIOLATION;
+ }
+ case CHUNK_EVENT_STATE:
+ switch (type) {
+ case 19: return SAMPLE_EVENT_CEILING; // Ndl exceeded
+ case 35: case 38: return SAMPLE_EVENT_DECOSTOP; // At/Ahead Deco Stop
+ case 36: case 39: return SAMPLE_EVENT_DEEPSTOP; // At/Ahead Deep Stop
+ case 37: case 40: return SAMPLE_EVENT_SAFETYSTOP; // At/Ahead Safety Stop
+ default: return SAMPLE_EVENT_BOOKMARK;
+ }
+ case CHUNK_EVENT_NOTIFY:
+ switch (type) {
+ case 11: return SAMPLE_EVENT_GASCHANGE; // Gas Switch
+ default: return SAMPLE_EVENT_BOOKMARK;
+ }
+ default:
+ return SAMPLE_EVENT_BOOKMARK;
+ }
+}
+
+// Emit a Suunto-Nautic vendor record at the given sample time. Every such
+// record starts with a VENDOR_KIND_* byte so a single vendor type can carry
+// several kinds of non-standard telemetry (battery, GPS accuracy, IMU, ...).
+static void
+suunto_nautic_emit_vendor (dc_sample_callback_t callback, void *userdata,
+ int time_ms, const unsigned char *rec, unsigned int size)
+{
+ dc_sample_value_t sample = {0};
+ sample.time = (unsigned int) time_ms;
+ callback (DC_SAMPLE_TIME, &sample, userdata);
+ sample.vendor.type = SAMPLE_VENDOR_SUUNTO_NAUTIC;
+ sample.vendor.size = size;
+ sample.vendor.data = rec;
+ callback (DC_SAMPLE_VENDOR, &sample, userdata);
+}
+
+static dc_status_t
+suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback, void *userdata)
+{
+ suunto_nautic_parser_t *parser = (suunto_nautic_parser_t *) abstract;
+
+ if (abstract->size < SBEM_MAGIC_SIZE || memcmp (abstract->data, "SBEM0103", SBEM_MAGIC_SIZE) != 0) {
+ ERROR (abstract->context, "Unexpected magic in the SBEM stream.");
+ return DC_STATUS_DATAFORMAT;
+ }
+
+ // The profile (/Data) is the first SBEM section; an optional /Summary
+ // section (gradient factors, gas mix) is appended after it. Walk only
+ // the profile here; parse /Summary separately below.
+ size_t profile_size = suunto_nautic_find_summary (abstract->data, abstract->size);
+
+ unsigned int offset = SBEM_MAGIC_SIZE;
+ // Time is delta-encoded: every chunk (except the timeline base 0x01)
+ // begins with a signed int16 LE millisecond delta at payload[0:2]. The
+ // running sum is the absolute sample time -- there is no per-sample
+ // absolute timestamp and no fixed sample rate.
+ int time_ms = 0;
+
+ // Dive phase, from CHUNK_DIVE_STATE. Dive time is the TOTAL time spent in
+ // the Diving state (sum of every Diving span), which matches the app's
+ // DiveTimeMax on multi-level dives that briefly surface mid-dive -- taking
+ // only the longest single span undercounts those. A spurious ~0 s startup
+ // blip contributes nothing. Average depth is taken over Diving samples only;
+ // counting from the first raw sample would include the pre-dive/surface
+ // phase and skew both low.
+ unsigned int dive_state = DIVE_STATE_IDLING;
+ int diving_start_ms = -1;
+ int total_dive_ms = 0;
+
+ double maxdepth = 0.0;
+ double depth_sum = 0.0;
+ unsigned int depth_count = 0;
+
+ unsigned int have_temperature = 0;
+ double temperature_minimum = 0.0;
+ double temperature_maximum = 0.0;
+
+ unsigned int ntanks = 0;
+ suunto_nautic_tank_t tank[MAX_TANKS];
+ memset (tank, 0, sizeof (tank));
+ // Each cylinder slot has two pressure fields: Pressure (+2, the main
+ // transmitter) and Pressure2 (+6, a sidemount second transmitter). Map
+ // each (slot, field) with data to a compacted tank index. Pressure2 is
+ // gated: a real second transmitter produces a continuous non-zero
+ // curve, whereas a slot with no second transmitter emits a single
+ // spurious Pressure2 sample then zeros -- so only accept Pressure2 once
+ // it has read non-zero at least twice (p2_first holds the first value).
+ int tankmap[MAX_TANKS * 2];
+ int p2_first[MAX_TANKS];
+ for (unsigned int ti = 0; ti < MAX_TANKS * 2; ti++)
+ tankmap[ti] = -1;
+ for (unsigned int ti = 0; ti < MAX_TANKS; ti++)
+ p2_first[ti] = -1;
+
+ unsigned int have_location = 0;
+ dc_location_t location = {0};
+
+ unsigned int have_atmospheric = 0;
+ double atmospheric = 0.0;
+
+ unsigned int have_datetime = 0;
+
+ // GPS horizontal/vertical position error, int8-delta-accumulated (chunk 0x0E).
+ int ehpe = 0, evpe = 0;
+
+ sbem_chunk_t chunk;
+ while (suunto_nautic_sbem_next (abstract->data, (unsigned int) profile_size, &offset, &chunk)) {
+ // Advance the clock by this chunk's leading ms delta (all groups
+ // except the timeline base carry one).
+ if (chunk.id != CHUNK_TIMELINE_BASE && chunk.size >= 2)
+ time_ms += (int16_t) array_uint16_le (chunk.data);
+
+ if (chunk.id == CHUNK_PROFILE_1HZ && chunk.size >= 18) {
+ double temperature = array_uint16_le (chunk.data + 16) / 100.0 - 273.15;
+
+ if (!have_temperature) {
+ temperature_minimum = temperature_maximum = temperature;
+ have_temperature = 1;
+ } else {
+ if (temperature < temperature_minimum)
+ temperature_minimum = temperature;
+ if (temperature > temperature_maximum)
+ temperature_maximum = temperature;
+ }
+
+ if (callback) {
+ dc_sample_value_t sample = {0};
+ sample.time = (unsigned int) time_ms;
+ callback (DC_SAMPLE_TIME, &sample, userdata);
+ sample.temperature = temperature;
+ callback (DC_SAMPLE_TEMPERATURE, &sample, userdata);
+ }
+ } else if (chunk.id == CHUNK_EXTENDED_STATUS) {
+ if (chunk.size >= 6) {
+ double depth = array_float_le (chunk.data + 2);
+
+ if (depth > maxdepth)
+ maxdepth = depth;
+ // Average only over the Diving phase (matches the app).
+ if (dive_state == DIVE_STATE_DIVING) {
+ depth_sum += depth;
+ depth_count++;
+ }
+
+ if (callback) {
+ dc_sample_value_t sample = {0};
+ sample.time = (unsigned int) time_ms;
+ callback (DC_SAMPLE_TIME, &sample, userdata);
+ sample.depth = depth;
+ callback (DC_SAMPLE_DEPTH, &sample, userdata);
+ }
+ }
+
+ // Cylinders array: up to 8 elements of 18 bytes, starting at
+ // offset 42 (idx:1, ?:1, pressure:4 LE Pa, pressure2:4 LE Pa, ...).
+ // Read each tank only if its record fits: the shorter Nautic S
+ // extended-status (141 B) holds fewer tank slots than the 195 B
+ // Ocean/Nautic one, but tank 0's pressure is at the same offset 44
+ // on both. Requiring the full 8-slot array (186 B) would drop all
+ // tank pressure on the shorter Nautic S chunk.
+ {
+ // Read every cylinder slot whose FULL 18-byte record fits in
+ // this chunk: the 195 B chunk holds all 8, the shorter 141 B
+ // chunk (Nautic S and some Ocean firmware) holds slots 0-4.
+ // Requiring the whole record (base + 18) rather than just the
+ // pressure field excludes the partial slot past the end, whose
+ // bytes would otherwise occasionally look like a phantom tank.
+ // Each real record starts with its own index byte, so stop at
+ // the first slot whose leading byte isn't its index. Slots 0-4
+ // carry idx 0-4 on the 141 B chunk; tank 0 pressure is at offset
+ // 44 on both layouts.
+ for (unsigned int i = 0; i < MAX_TANKS; i++) {
+ unsigned int base = 42 + i * 18;
+ if (base + 18 > chunk.size)
+ break; // full tank record doesn't fit this chunk
+ if (chunk.data[base] != i)
+ break; // not a real tank slot
+ 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)
+ continue;
+ unsigned int key = i * 2 + field;
+ if (field == 1 && tankmap[key] < 0) {
+ // Defer creating a Pressure2 tank until its second non-zero
+ // reading, so a lone spurious sample doesn't become a phantom.
+ if (p2_first[i] < 0) {
+ p2_first[i] = (int) pressure_pa;
+ continue;
+ }
+ }
+ double bar = pressure_pa / 100000.0;
+ if (tankmap[key] < 0) {
+ if (ntanks >= MAX_TANKS)
+ continue;
+ tankmap[key] = (int) ntanks;
+ tank[ntanks].used = 1;
+ // Begin from the first observed reading (the deferred one for Pressure2).
+ tank[ntanks].beginpressure = (field == 1 && p2_first[i] >= 0)
+ ? p2_first[i] / 100000.0 : bar;
+ ntanks++;
+ }
+ unsigned int t = (unsigned int) tankmap[key];
+ tank[t].endpressure = bar;
+ if (callback) {
+ dc_sample_value_t sample = {0};
+ sample.time = (unsigned int) time_ms;
+ callback (DC_SAMPLE_TIME, &sample, userdata);
+ sample.pressure.tank = t;
+ sample.pressure.value = bar;
+ callback (DC_SAMPLE_PRESSURE, &sample, userdata);
+ }
+ }
+ }
+ }
+
+ // Deco/safety fields. Offsets validated 244/244 against the app's
+ // export: TTS uint16 @22 (s), NDL int16 @30 (s), Ceiling float32 @38
+ // (m). Ceiling/NDL/TTS use the standard DC_SAMPLE_DECO channel.
+ if (callback && chunk.size >= 42) {
+ unsigned int tts = array_uint16_le (chunk.data + 22);
+ int ndl = (int16_t) array_uint16_le (chunk.data + 30);
+ double ceiling = array_float_le (chunk.data + 38);
+
+ dc_sample_value_t t = {0};
+ t.time = (unsigned int) time_ms;
+ callback (DC_SAMPLE_TIME, &t, userdata);
+
+ dc_sample_value_t deco = {0};
+ if (ceiling > 0.0) {
+ deco.deco.type = DC_DECO_DECOSTOP;
+ deco.deco.depth = ceiling;
+ deco.deco.tts = tts;
+ } else {
+ deco.deco.type = DC_DECO_NDL;
+ deco.deco.time = ndl > 0 ? (unsigned int) ndl : 0;
+ deco.deco.tts = tts;
+ }
+ callback (DC_SAMPLE_DECO, &deco, userdata);
+ }
+
+ // Real-time gradient factors -> vendor kind 5 (no standard channel).
+ // gf99 @186 and surface @190 sit past the cylinder array; the
+ // leading-tissue GF @78 is inside that span, so it's only reliable
+ // while the upper tank slots are unused (as on this reference dive).
+ if (callback && chunk.size >= 192) {
+ int gf99 = (int16_t) array_uint16_le (chunk.data + 186);
+ int gf_surf = (int16_t) array_uint16_le (chunk.data + 190);
+ int gf_lead = (int16_t) array_uint16_le (chunk.data + 78);
+ unsigned char rec[7];
+ rec[0] = VENDOR_KIND_GF;
+ rec[1] = gf99 & 0xFF; rec[2] = (gf99 >> 8) & 0xFF;
+ rec[3] = gf_surf & 0xFF; rec[4] = (gf_surf >> 8) & 0xFF;
+ rec[5] = gf_lead & 0xFF; rec[6] = (gf_lead >> 8) & 0xFF;
+ suunto_nautic_emit_vendor (callback, userdata, time_ms, rec, sizeof (rec));
+ }
+ } else if (chunk.id == CHUNK_GPS && chunk.size >= 18) {
+ // Payload: [timeDelta:2][UTC:8 ms LE][lat:4][lon:4]. UTC is an
+ // absolute UNIX time in milliseconds; subtracting this sample's
+ // relative time (time_ms) yields the stream-start epoch, i.e. the
+ // dive start (== the logbook id, confirmed to the second). This is
+ // the only absolute clock in the stream, so the first GPS fix sets
+ // the dive datetime.
+ unsigned long long utc_ms = array_uint64_le (chunk.data + 2);
+ int lat_raw = (int) array_uint32_le (chunk.data + 10);
+ int lon_raw = (int) array_uint32_le (chunk.data + 14);
+
+ if (!have_datetime && utc_ms > (unsigned long long) time_ms) {
+ have_datetime = 1;
+ parser->datetime = (dc_ticks_t) ((utc_ms - (unsigned long long) time_ms) / 1000);
+ }
+
+ if (!have_location) {
+ have_location = 1;
+ location.latitude = lat_raw / 1.0e7;
+ location.longitude = lon_raw / 1.0e7;
+ location.altitude = 0.0;
+ }
+
+ if (callback) {
+ dc_sample_value_t sample = {0};
+ sample.time = (unsigned int) time_ms;
+ callback (DC_SAMPLE_TIME, &sample, userdata);
+ sample.location.latitude = lat_raw / 1.0e7;
+ sample.location.longitude = lon_raw / 1.0e7;
+ sample.location.altitude = 0.0;
+ callback (DC_SAMPLE_LOCATION, &sample, userdata);
+ }
+ } else if (chunk.id == CHUNK_DIVE_STATE && chunk.size >= 3) {
+ unsigned int new_state = chunk.data[2];
+ if (new_state == DIVE_STATE_DIVING && dive_state != DIVE_STATE_DIVING) {
+ diving_start_ms = time_ms;
+ } else if (new_state != DIVE_STATE_DIVING && dive_state == DIVE_STATE_DIVING) {
+ if (diving_start_ms >= 0)
+ total_dive_ms += time_ms - diving_start_ms;
+ diving_start_ms = -1;
+ }
+ dive_state = new_state;
+ } else if ((chunk.id == CHUNK_EVENT_ALARM || chunk.id == CHUNK_EVENT_WARNING ||
+ chunk.id == CHUNK_EVENT_NOTIFY || chunk.id == CHUNK_EVENT_STATE) && chunk.size >= 4) {
+ // [timeDelta:2][Type:1][Active:1]; Active 1=begin, 0=end.
+ // The libdivecomputer event vocabulary can't express Suunto's full
+ // set (e.g. "Safety Stop Ahead" vs "At Safety Stop" both map to
+ // SAFETYSTOP), so alongside the mapped type we pass the native
+ // (subgroup, type) through event.value as (chunk_id << 8 | type).
+ // Consumers that want the exact Suunto label decode it from there;
+ // standard consumers use event.type as usual. (Gas switch keeps
+ // event.value as the gas number, per libdivecomputer convention.)
+ if (callback) {
+ dc_sample_value_t sample = {0};
+ sample.time = (unsigned int) time_ms;
+ callback (DC_SAMPLE_TIME, &sample, userdata);
+ sample.event.type = suunto_nautic_map_event (chunk.id, chunk.data[2]);
+ sample.event.flags = chunk.data[3] ? SAMPLE_FLAGS_BEGIN : SAMPLE_FLAGS_END;
+ sample.event.value = (chunk.id << 8) | chunk.data[2];
+ callback (DC_SAMPLE_EVENT, &sample, userdata);
+ }
+ } else if (chunk.id == CHUNK_OOAM && chunk.size >= 3) {
+ // [timeDelta:2][Type:1]; one-shot dive-end reason (Ooam.Type: Out of
+ // battery / Ceiling broken / SW crash / Max depth / Algorithm changed
+ // / Gauge dive). No Active byte. Emitted as a begin-edge event; the
+ // native (subgroup, type) is passed in event.value = (chunk_id<<8|type)
+ // for the precise Suunto label, same convention as the other events.
+ if (callback) {
+ dc_sample_value_t sample = {0};
+ sample.time = (unsigned int) time_ms;
+ callback (DC_SAMPLE_TIME, &sample, userdata);
+ sample.event.type = suunto_nautic_map_event (chunk.id, chunk.data[2]);
+ sample.event.flags = SAMPLE_FLAGS_BEGIN;
+ sample.event.value = (chunk.id << 8) | chunk.data[2];
+ callback (DC_SAMPLE_EVENT, &sample, userdata);
+ }
+ } else if (chunk.id == CHUNK_GAS_SWITCH && chunk.size >= 4) {
+ // [timeDelta:2][gasnumber:int16 LE].
+ if (callback) {
+ dc_sample_value_t sample = {0};
+ sample.time = (unsigned int) time_ms;
+ callback (DC_SAMPLE_TIME, &sample, userdata);
+ 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);
+ callback (DC_SAMPLE_EVENT, &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,
+ // so it's left out for now.)
+ if (callback) {
+ unsigned int voltage_mv = array_uint16_le (chunk.data + 4);
+ unsigned int charge_permille = chunk.data[6] * 10; // % -> permille
+ unsigned char rec[5];
+ rec[0] = VENDOR_KIND_BATTERY;
+ rec[1] = voltage_mv & 0xFF;
+ rec[2] = (voltage_mv >> 8) & 0xFF;
+ rec[3] = charge_permille & 0xFF;
+ rec[4] = (charge_permille >> 8) & 0xFF;
+ suunto_nautic_emit_vendor (callback, userdata, time_ms, rec, sizeof (rec));
+ }
+ } else if (chunk.id == CHUNK_GPS_ACCURACY && chunk.size >= 4) {
+ // EHPE/EVPE are int8 deltas accumulated from zero -> DC_SAMPLE_VENDOR
+ // kind 2, absolute metres.
+ ehpe += (int8_t) chunk.data[2];
+ evpe += (int8_t) chunk.data[3];
+ if (ehpe < 0) ehpe = 0;
+ if (evpe < 0) evpe = 0;
+ if (callback) {
+ unsigned int e = (unsigned int) ehpe, v = (unsigned int) evpe;
+ unsigned char rec[5];
+ rec[0] = VENDOR_KIND_GPS_ACCURACY;
+ rec[1] = e & 0xFF; rec[2] = (e >> 8) & 0xFF;
+ rec[3] = v & 0xFF; rec[4] = (v >> 8) & 0xFF;
+ suunto_nautic_emit_vendor (callback, userdata, time_ms, rec, sizeof (rec));
+ }
+ } else if ((chunk.id == CHUNK_IMU || chunk.id == CHUNK_IMU_ALT) && chunk.size >= 24) {
+ // High-rate IMU: 9x int16 (accel/gyro/mag X,Y,Z) at offset 6, already
+ // little-endian -> DC_SAMPLE_VENDOR kind 3, passed through verbatim.
+ // Matched by shape (payload >= 24) across both id variants (0x23 on
+ // 195-byte watches, 0x22 on 141-byte ones) so IMU decodes on all
+ // firmware, not just the id-0x23 devices.
+ if (callback) {
+ unsigned char rec[1 + 18];
+ rec[0] = VENDOR_KIND_IMU;
+ memcpy (rec + 1, chunk.data + 6, 18);
+ suunto_nautic_emit_vendor (callback, userdata, time_ms, rec, sizeof (rec));
+ }
+ } else if ((chunk.id == CHUNK_DIVEROUTE_FEATURES || chunk.id == CHUNK_IMU) &&
+ chunk.size >= 10 && chunk.size <= 16) {
+ // DiveRouteFeatures: 5x uint16 at offset 6 -> DC_SAMPLE_VENDOR kind 4.
+ // Id is 0x24 on 195-byte watches and 0x23 (small, payload 16) on
+ // 141-byte ones; the payload bound (<= 16) excludes the 141-byte
+ // 0x24 summary record so it is no longer misread as this channel.
+ // NOT the dive route (which the watch does not store); semantics TBD.
+ if (callback) {
+ unsigned char rec[1 + 10];
+ rec[0] = VENDOR_KIND_DIVEROUTE_FEATURES;
+ memcpy (rec + 1, chunk.data + 6, 10);
+ suunto_nautic_emit_vendor (callback, userdata, time_ms, rec, sizeof (rec));
+ }
+ } else if (chunk.id == CHUNK_SURFACE_PRESSURE && chunk.size >= 6) {
+ // 3 Float32 values at offset 2/6/10 (SurfacePressure,
+ // MaxSurfacePressure, MinSurfacePressure), Pa -- offset
+ // 2, not 0: like chunk 0x16's Depth field, there are 2
+ // leading bytes before the data starts. DC_FIELD_ATMOSPHERIC
+ // is a single ambient-pressure reading in bar, so only
+ // SurfacePressure (offset 2) is used; last one logged wins.
+ have_atmospheric = 1;
+ atmospheric = array_float_le (chunk.data + 2) / 100000.0;
+ }
+ }
+
+ // A dive still in progress at the end of the stream closes the final span.
+ if (dive_state == DIVE_STATE_DIVING && diving_start_ms >= 0)
+ total_dive_ms += time_ms - diving_start_ms;
+
+ // Dive time = total time in the Diving state (seconds). Fall back to the
+ // full elapsed time if no DiveState markers were seen.
+ if (total_dive_ms > 0)
+ parser->divetime = (unsigned int) ((total_dive_ms + 500) / 1000);
+ else
+ parser->divetime = (unsigned int) ((time_ms + 500) / 1000);
+ parser->maxdepth = maxdepth;
+ parser->avgdepth = depth_count ? depth_sum / depth_count : 0.0;
+ parser->have_temperature = have_temperature;
+ parser->temperature_minimum = temperature_minimum;
+ parser->temperature_maximum = temperature_maximum;
+ parser->ntanks = ntanks;
+ memcpy (parser->tank, tank, sizeof (tank));
+ parser->have_location = have_location;
+ parser->location = location;
+ parser->have_atmospheric = have_atmospheric;
+ parser->atmospheric = atmospheric;
+ parser->have_datetime = have_datetime;
+
+ // Gradient factors and gas mixes from the appended /Summary section.
+ parser->ngasmixes = 0;
+ parser->have_decomodel = 0;
+ memset (parser->gasmix, 0, sizeof (parser->gasmix));
+ memset (&parser->decomodel, 0, sizeof (parser->decomodel));
+ if (profile_size < abstract->size) {
+ suunto_nautic_parse_summary (parser, abstract->data + profile_size,
+ abstract->size - profile_size);
+ }
+
+ parser->cached = 1;
+
+ return DC_STATUS_SUCCESS;
+}
+
+static dc_status_t
+suunto_nautic_parser_get_datetime (dc_parser_t *abstract, dc_datetime_t *datetime)
+{
+ suunto_nautic_parser_t *parser = (suunto_nautic_parser_t *) abstract;
+
+ if (!parser->cached) {
+ dc_status_t status = suunto_nautic_parser_parse (abstract, NULL, NULL);
+ if (status != DC_STATUS_SUCCESS)
+ return status;
+ }
+
+ // The dive start is derived from the first GPS fix's absolute UTC. A dive
+ // without a surface GPS fix has no absolute clock in the stream; the caller
+ // falls back to the logbook id (which is that same timestamp).
+ if (!parser->have_datetime)
+ return DC_STATUS_UNSUPPORTED;
+
+ if (datetime && !dc_datetime_gmtime (datetime, parser->datetime))
+ return DC_STATUS_DATAFORMAT;
+
+ return DC_STATUS_SUCCESS;
+}
+
+static dc_status_t
+suunto_nautic_parser_get_field (dc_parser_t *abstract, dc_field_type_t type, unsigned int flags, void *value)
+{
+ dc_status_t status = DC_STATUS_SUCCESS;
+ suunto_nautic_parser_t *parser = (suunto_nautic_parser_t *) abstract;
+
+ if (!parser->cached) {
+ status = suunto_nautic_parser_parse (abstract, NULL, NULL);
+ if (status != DC_STATUS_SUCCESS)
+ return status;
+ }
+
+ if (value == NULL)
+ return DC_STATUS_SUCCESS;
+
+ dc_tank_t *tank = (dc_tank_t *) value;
+
+ switch (type) {
+ case DC_FIELD_DIVETIME:
+ *((unsigned int *) value) = parser->divetime;
+ break;
+ case DC_FIELD_MAXDEPTH:
+ *((double *) value) = parser->maxdepth;
+ break;
+ case DC_FIELD_AVGDEPTH:
+ *((double *) value) = parser->avgdepth;
+ break;
+ case DC_FIELD_TEMPERATURE_MINIMUM:
+ if (!parser->have_temperature)
+ return DC_STATUS_UNSUPPORTED;
+ *((double *) value) = parser->temperature_minimum;
+ break;
+ case DC_FIELD_TEMPERATURE_MAXIMUM:
+ if (!parser->have_temperature)
+ return DC_STATUS_UNSUPPORTED;
+ *((double *) value) = parser->temperature_maximum;
+ break;
+ case DC_FIELD_TANK_COUNT:
+ *((unsigned int *) value) = parser->ntanks;
+ break;
+ case DC_FIELD_TANK:
+ if (flags >= MAX_TANKS || !parser->tank[flags].used)
+ return DC_STATUS_INVALIDARGS;
+ tank->type = DC_TANKVOLUME_NONE;
+ tank->volume = 0.0;
+ tank->workpressure = 0.0;
+ tank->beginpressure = parser->tank[flags].beginpressure;
+ tank->endpressure = parser->tank[flags].endpressure;
+ tank->gasmix = DC_GASMIX_UNKNOWN;
+ tank->usage = DC_USAGE_NONE;
+ break;
+ case DC_FIELD_LOCATION:
+ if (!parser->have_location)
+ return DC_STATUS_UNSUPPORTED;
+ *((dc_location_t *) value) = parser->location;
+ break;
+ case DC_FIELD_ATMOSPHERIC:
+ if (!parser->have_atmospheric)
+ return DC_STATUS_UNSUPPORTED;
+ *((double *) value) = parser->atmospheric;
+ break;
+ case DC_FIELD_GASMIX_COUNT:
+ *((unsigned int *) value) = parser->ngasmixes;
+ break;
+ case DC_FIELD_GASMIX:
+ if (flags >= parser->ngasmixes)
+ return DC_STATUS_INVALIDARGS;
+ *((dc_gasmix_t *) value) = parser->gasmix[flags];
+ break;
+ case DC_FIELD_DECOMODEL:
+ if (!parser->have_decomodel)
+ return DC_STATUS_UNSUPPORTED;
+ *((dc_decomodel_t *) value) = parser->decomodel;
+ break;
+ default:
+ return DC_STATUS_UNSUPPORTED;
+ }
+
+ return DC_STATUS_SUCCESS;
+}
+
+static dc_status_t
+suunto_nautic_parser_samples_foreach (dc_parser_t *abstract, dc_sample_callback_t callback, void *userdata)
+{
+ return suunto_nautic_parser_parse (abstract, callback, userdata);
+}