From 6dac6e48f929d4e90fb7d806e6784d5acc1070cb Mon Sep 17 00:00:00 2001 From: Latisha Date: Thu, 3 Sep 2026 19:19:13 +0800 Subject: [PATCH 1/3] suunto: add Suunto Nautic BLE device driver Add support for the Suunto Nautic / Ocean ("Vaasa" generation) BLE dive computers. The driver speaks the HDLC-framed RPC protocol over BLE: path-addressed GET requests, a stream-fetch download of the dive data, and a short 0x0D fetch for the logbook listing. Dives are enumerated from /Logbook/Entries (each LogId is a UNIX timestamp) and downloaded newest-first through the standard dc_device_foreach() interface. Each dive is a sequence of MDS-wrapped, Heatshrink (LZSS) compressed blocks that decode to an SBEM0103 container; a vendored copy of the Heatshrink decoder lives in src/heatshrink/. Registers the DC_FAMILY_SUUNTO_NAUTIC family, the descriptor table entries and BLE name filter, and the device_open dispatch. --- configure.ac | 2 +- include/libdivecomputer/common.h | 2 + src/Makefile.am | 3 + src/descriptor.c | 19 + src/device.c | 4 + src/heatshrink/LICENSE | 14 + src/heatshrink/heatshrink_common.h | 20 + src/heatshrink/heatshrink_config.h | 26 + src/heatshrink/heatshrink_decoder.c | 367 +++++++++ src/heatshrink/heatshrink_decoder.h | 100 +++ src/suunto_nautic.c | 1071 +++++++++++++++++++++++++++ src/suunto_nautic.h | 105 +++ 12 files changed, 1732 insertions(+), 1 deletion(-) create mode 100644 src/heatshrink/LICENSE create mode 100644 src/heatshrink/heatshrink_common.h create mode 100644 src/heatshrink/heatshrink_config.h create mode 100644 src/heatshrink/heatshrink_decoder.c create mode 100644 src/heatshrink/heatshrink_decoder.h create mode 100644 src/suunto_nautic.c create mode 100644 src/suunto_nautic.h 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/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/src/Makefile.am b/src/Makefile.am index 583546b1..8bb5363c 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 \ + 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/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 */ From b34141bad84f8a0a740baa065c40fcb885cbe327 Mon Sep 17 00:00:00 2001 From: Latisha Date: Thu, 3 Sep 2026 19:19:26 +0800 Subject: [PATCH 2/3] suunto: add Suunto Nautic dive parser Parse the SBEM0103 TLV stream from a downloaded Suunto Nautic / Ocean dive into libdivecomputer fields and samples. Decodes depth, temperature, absolute and surface pressure, cylinder pressures, NDL and time-to-surface, GPS, gradient factors and gas mixes, and dive events. The sample clock is delta-encoded per chunk; the dive start time is reconstructed from the absolute UTC carried in GPS fixes. 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. The TLV walk resynchronises past localized Heatshrink decompression artifacts by validating fixed-length chunk ids against their expected length. --- include/libdivecomputer/parser.h | 3 +- src/Makefile.am | 2 +- src/parser.c | 4 + src/suunto_nautic_parser.c | 892 +++++++++++++++++++++++++++++++ 4 files changed, 899 insertions(+), 2 deletions(-) create mode 100644 src/suunto_nautic_parser.c 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 8bb5363c..2b5c32eb 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -83,7 +83,7 @@ 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.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 \ 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_parser.c b/src/suunto_nautic_parser.c new file mode 100644 index 00000000..e5bdb15f --- /dev/null +++ b/src/suunto_nautic_parser.c @@ -0,0 +1,892 @@ +/* + * 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 "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); +} From a356584431227040b495aaaae5c00b5db8e9efa7 Mon Sep 17 00:00:00 2001 From: urbamax Date: Thu, 3 Sep 2026 20:26:16 +0200 Subject: [PATCH 3/3] suunto: fix the CI build for the Nautic driver The Android and MSVC project files are maintained by hand and were not updated when the Suunto Nautic driver was added, so the Android and Visual Studio CI jobs failed to build the new sources. Add suunto_nautic.c, suunto_nautic_parser.c and the bundled heatshrink decoder to contrib/android/Android.mk and contrib/msvc/libdivecomputer.vcxproj, and include in suunto_nautic_parser.c which uses the fixed-width integer types directly. Co-Authored-By: Claude Sonnet 5 --- contrib/android/Android.mk | 3 +++ contrib/msvc/libdivecomputer.vcxproj | 7 +++++++ src/suunto_nautic_parser.c | 1 + 3 files changed, 11 insertions(+) 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/src/suunto_nautic_parser.c b/src/suunto_nautic_parser.c index e5bdb15f..30cc0d29 100644 --- a/src/suunto_nautic_parser.c +++ b/src/suunto_nautic_parser.c @@ -49,6 +49,7 @@ #include #include +#include #include "suunto_nautic.h" #include "context-private.h"