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