diff --git a/include/libdivecomputer/Makefile.am b/include/libdivecomputer/Makefile.am index 401f839..9b5383d 100644 --- a/include/libdivecomputer/Makefile.am +++ b/include/libdivecomputer/Makefile.am @@ -31,4 +31,5 @@ libdivecomputer_HEADERS = \ hw_ostc.h \ hw_ostc3.h \ atomics_cobalt.h \ - divesystem_idive.h + divesystem_idive.h \ + suunto_nautic.h diff --git a/include/libdivecomputer/suunto_nautic.h b/include/libdivecomputer/suunto_nautic.h index 28baa29..7a6671d 100644 --- a/include/libdivecomputer/suunto_nautic.h +++ b/include/libdivecomputer/suunto_nautic.h @@ -68,7 +68,7 @@ suunto_nautic_device_fetch_raw (dc_device_t *device, const char *path, dc_buffer /* * List dive ids (each is a UNIX-timestamp LogId), newest-first, without - * downloading the dives. `ids` receives the ids packed as little-endian + * downloading the dives. Follows every /Logbook/Entries page. `ids` receives the ids packed as little-endian * uint32 (4 bytes each); read them back as a plain array. This is the cheap * way to enumerate the logbook; the entry-parsing lives here in the driver so * callers don't reimplement it. @@ -88,7 +88,7 @@ suunto_nautic_extract_entry_ids (const unsigned char data[], size_t size, unsign /* * Download a dive's /Summary (metadata: gradient factors, gas mix, ...). * Uses the paginated 0x0D fetch; `summary` receives the raw, uncompressed - * SBEM0103 payload. The caller locates the "SBEM0103" signature and reads + * SBEM0103 payload (page headers and CRCs stripped). The caller locates the "SBEM0103" signature and reads * fields at documented offsets from it (unlike the profile from * suunto_nautic_device_download(), this is not Heatshrink-compressed). */ @@ -99,7 +99,12 @@ suunto_nautic_device_download_summary (dc_device_t *device, const char *logbook_ * Download and decompress a specific logbook entry, given its numeric * id as it appears in a "/Logbook/byId//..." path (e.g. * "1787752091"). On success, `raw` holds the decoded SBEM0103 TLV - * stream (magic-verified), suitable for suunto_nautic_parser_create(). + * stream (magic-verified), suitable for suunto_nautic_parser_create(), + * with the /Summary SBEM appended when it could be fetched. + * + * The download is checked against the size /Logbook/Entries lists for the + * id. On a mismatch it returns DC_STATUS_DATAFORMAT with the (likely + * truncated) dive still in `raw`; on any other failure `raw` is empty. */ dc_status_t suunto_nautic_device_download (dc_device_t *device, const char *logbook_id, dc_buffer_t *raw); diff --git a/src/suunto_nautic.c b/src/suunto_nautic.c index 132f682..0499906 100644 --- a/src/suunto_nautic.c +++ b/src/suunto_nautic.c @@ -38,11 +38,24 @@ #define RPC_OP_STREAM_FETCH1 0x0B #define RPC_OP_FETCH 0x0D #define RPC_OP_STREAM_FETCH2 0x10 +#define RPC_OP_STREAM_STOP 0x11 // host -> watch: close the stream and release its handle +#define RPC_OP_STREAM_CHUNK 0x01 +#define RPC_OP_ACK 0x02 +#define RPC_OP_ACK_ALT 0x03 #define RPC_OP_DATA 0x05 +#define RPC_OP_STREAM_START 0x08 // watch -> host: stream accepted (200) or refused (e.g. 423 Locked) +#define RPC_OP_STREAM_END 0x09 // watch -> host: the stream is closed (answers STREAM_STOP) + +// Fetch parameter types (Whiteboard type ids): the paged /Summary offset is an +// int32, /Logbook/Entries' StartAfterId a uint32. +#define RPC_PARAM_INT32 0x06 +#define RPC_PARAM_UINT32 0x07 #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 16-bit LE message id; a reply echoes its request's. +#define RPC_MSGID_OFFSET 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 @@ -68,13 +81,31 @@ // 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 +// Runaway guard on stream frames, not a protocol limit: the stream ends on +// inter-frame silence or STREAM_END. ~278 B per chunk, so 65536 is ~18 MB; the +// old 4096 cap (~1.14 MB) truncated the end of most real dives. +#define MAX_CHUNKS 65536 + +// How many times to issue a dive's stream when the watch refuses it (423). +#define STREAM_ATTEMPTS 3 + +// How many times to download a dive whose size doesn't match its listing. +#define DOWNLOAD_ATTEMPTS 2 + +// Each /Summary page is [header:11][data][crc:4]; the header carries the data +// length as a u16 at offset 3, and the paging offset counts data bytes only. +#define SUMMARY_PAGE_HEADER_SIZE 11 +#define SUMMARY_PAGE_LENGTH_OFFSET 3 -// Safety cap on paginated-fetch pages (a Summary is a handful of pages). +// A /Summary is ~2-2.4 KB; bounds the listed-size check when it is missing. +#define SUMMARY_MAX_SIZE 8192 + +// A /Logbook/Entries record is [start][end][w1][w2][size][pad] (u32 LE each); +// size = compressed /Data + /Summary data bytes, exactly. +#define ENTRY_SIZE_OFFSET 16 + +// Safety cap on paginated-fetch pages (a Summary is a handful of pages, and +// /Logbook/Entries one page per ~18 dives). #define MAX_PAGES 64 // The Suunto "MDS" chunk header wrapping each compressed block: 28 bytes, @@ -252,17 +283,17 @@ suunto_nautic_build_short_fetch (unsigned char packet[], unsigned int size, unsi 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). +// Build a 0x0D fetch carrying one 4-byte parameter. Payload is +// [seq:2 LE][handle:3][01 80 00][count=01][type:2 LE][value:4 LE]. Used for the +// paged /Summary (int32 offset, in data bytes) and for /Logbook/Entries pages +// after the first (uint32 StartAfterId). The watch answers each page with +// 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) +suunto_nautic_build_param_fetch (unsigned char packet[], unsigned int size, unsigned int *out_len, + unsigned int seq, const unsigned char handle[3], unsigned int type, unsigned int value) { - 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 char flags[] = { 0x01, 0x80, 0x00, 0x01, (unsigned char) type, 0x00 }; + unsigned int payload = 3 + (unsigned int) sizeof (flags) + 4; // handle(3) + flags(6) + value(4) unsigned int len = 4 + 2 + payload + RPC_CRC_SIZE; if (len > size) return DC_STATUS_INVALIDARGS; @@ -273,7 +304,7 @@ suunto_nautic_build_paginated_fetch (unsigned char packet[], unsigned int size, 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); + array_uint32_le_set (packet + 9 + (unsigned int) sizeof (flags), value); unsigned int crc = checksum_crc32r (packet, 6 + payload); array_uint32_le_set (packet + 6 + payload, crc); @@ -282,6 +313,47 @@ suunto_nautic_build_paginated_fetch (unsigned char packet[], unsigned int size, return DC_STATUS_SUCCESS; } +// A well-formed RPC frame from the watch starts with the 0xA5 magic and is long +// enough to carry an opcode. Anything shorter is line noise or a truncated read. +static int +suunto_nautic_frame_wellformed (const unsigned char *packet, size_t len) +{ + return len >= 2 && packet[0] == 0xA5; +} + +// The reply to a GET: an ACK echoing the request's message id. Anything else +// (a leftover 0x01 chunk of an unfinished stream, another client's traffic) is +// not ours, and taking its handle would address the wrong resource. +static int +suunto_nautic_frame_is_reply (const unsigned char *packet, size_t len, unsigned int msgid) +{ + return len >= RPC_HANDLE_OFFSET + 3 && packet[0] == 0xA5 && + (packet[1] == RPC_OP_ACK || packet[1] == RPC_OP_ACK_ALT) && + array_uint16_le (packet + RPC_MSGID_OFFSET) == msgid; +} + +// The DATA (0x05) frame a fetch is waiting for: our opcode, carrying the 3-byte +// session handle this fetch was issued against. +static int +suunto_nautic_frame_is_our_data (const unsigned char *packet, size_t len, const unsigned char handle[3]) +{ + return len >= RPC_HANDLE_OFFSET + 3 && + packet[0] == 0xA5 && packet[1] == RPC_OP_DATA && + memcmp (packet + RPC_HANDLE_OFFSET, handle, 3) == 0; +} + +// A single BLE link is shared: another client (most often the official Suunto +// app holding a live logbook subscription) can flood it with its own frames -- +// notably Whiteboard 0x07 subscribe-result traffic on a different handle. Those +// are well-formed frames that just aren't ours, so skip them freely rather than +// treating them as a data error. This cap only guards against an unbounded loop; +// in practice the read below times out first (a clean DC_STATUS_TIMEOUT) when +// our DATA frame never gets a turn on the contended link. +#define MAX_FOREIGN_SKIPS 256 +// A tight cap on genuinely malformed/truncated frames: those do signal a real +// data-format problem, so give up quickly with DC_STATUS_DATAFORMAT. +#define MAX_MALFORMED_SKIPS 8 + static dc_status_t suunto_nautic_transfer (suunto_nautic_device_t *device, const unsigned char req[], unsigned int rsize, dc_buffer_t *response) { @@ -298,15 +370,37 @@ suunto_nautic_transfer (suunto_nautic_device_t *device, const unsigned char req[ } if (response) { + unsigned int msgid = array_uint16_le (req + RPC_MSGID_OFFSET); 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; - } + unsigned int foreign_skips = 0; + unsigned int malformed_skips = 0; + for (;;) { + 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); + HEXDUMP (abstract->context, DC_LOGLEVEL_DEBUG, "RPC RSP", packet, len); + + if (suunto_nautic_frame_is_reply (packet, len, msgid)) + break; + if (suunto_nautic_frame_wellformed (packet, len)) { + if (++foreign_skips >= MAX_FOREIGN_SKIPS) { + ERROR (abstract->context, "No reply to message %u among the frames on the link.", msgid); + return DC_STATUS_TIMEOUT; + } + WARNING (abstract->context, "Skipping a frame that is not the reply to message %u (op 0x%02x).", + msgid, packet[1]); + continue; + } + if (++malformed_skips >= MAX_MALFORMED_SKIPS) { + ERROR (abstract->context, "Too many malformed frames waiting for the reply to message %u.", msgid); + return DC_STATUS_DATAFORMAT; + } + WARNING (abstract->context, "Skipping a malformed frame (" DC_PRINTF_SIZE " bytes).", len); + } dc_buffer_clear (response); if (!dc_buffer_append (response, packet, len)) { @@ -479,10 +573,36 @@ suunto_nautic_heatshrink_decompress (dc_context_t *context, const unsigned char 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. +// Append one MDS chunk frame's sub-payload to `raw`. A short or inconsistent +// frame is skipped with a warning; only an allocation failure is fatal. +static dc_status_t +suunto_nautic_append_chunk (dc_context_t *context, dc_buffer_t *raw, const unsigned char *packet, size_t len) +{ + if (len < MDS_HEADER_SIZE) { + WARNING (context, "MDS chunk shorter than the header (" DC_PRINTF_SIZE ").", len); + return DC_STATUS_SUCCESS; + } + + unsigned int chunk_size = array_uint16_le (packet + MDS_CHUNK_SIZE_OFFSET); + if (MDS_HEADER_SIZE + chunk_size > len) { + WARNING (context, "MDS chunk size (%u) exceeds the frame (" DC_PRINTF_SIZE ").", chunk_size, len); + return DC_STATUS_SUCCESS; + } + + if (!dc_buffer_append (raw, packet + MDS_HEADER_SIZE, chunk_size)) { + ERROR (context, "Failed to allocate memory."); + return DC_STATUS_NOMEMORY; + } + + return DC_STATUS_SUCCESS; +} + +// Performs the GET -> ACK(watch magic) -> FETCH1 -> FETCH2 -> stream-collect -> +// STREAM_STOP 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. Returns +// DC_STATUS_PROTOCOL when the watch refuses the stream (e.g. 423 Locked), so +// the caller can back off and retry. static dc_status_t suunto_nautic_device_stream_fetch (dc_device_t *abstract, const char *path, dc_buffer_t *raw) { @@ -491,7 +611,8 @@ suunto_nautic_device_stream_fetch (dc_device_t *abstract, const char *path, dc_b // 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. + // transfer; the stream triggers below use Watch_Magic+1/+2 and the + // STREAM_STOP Watch_Magic+3. dc_buffer_t *ack = dc_buffer_new (0); if (ack == NULL) return DC_STATUS_NOMEMORY; @@ -539,26 +660,21 @@ suunto_nautic_device_stream_fetch (dc_device_t *abstract, const char *path, dc_b 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. + // 3. Capture the MDS chunk frames (opcode 0x01). For a compressed + // endpoint, the concatenation of their sub-payloads is one continuous + // Heatshrink stream; chunk boundaries are a transport artifact. // // 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. + // inter-frame silence. MAX_CHUNKS is only a runaway guard. 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 int nframes = 0; + for (nframes = 0; nframes < MAX_CHUNKS; nframes++) { unsigned char packet[MAX_PACKET] = {0}; size_t len = 0; status = dc_iostream_read (device->iostream, packet, sizeof (packet), &len); @@ -572,78 +688,74 @@ suunto_nautic_device_stream_fetch (dc_device_t *abstract, const char *path, dc_b if (len == 0) break; - if (len >= 2 && packet[0] == 0xA5 && packet[1] == 0x09) + if (len >= 2 && packet[0] == 0xA5 && packet[1] == RPC_OP_STREAM_END) 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; + // A non-200 stream-start frame is a refusal, notably 423 Locked when + // the previous stream's handle hasn't been released yet. + if (len >= RPC_STATUS_OFFSET + 2 && packet[0] == 0xA5 && packet[1] == RPC_OP_STREAM_START) { + unsigned int frame_status = array_uint16_le (packet + RPC_STATUS_OFFSET); + if (frame_status != RPC_STATUS_OK) { + ERROR (abstract->context, "Watch refused the stream for %s (status %u).", path, frame_status); + return DC_STATUS_PROTOCOL; } + } - 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 (len >= 2 && packet[0] == 0xA5 && packet[1] == RPC_OP_STREAM_CHUNK) { + status = suunto_nautic_append_chunk (abstract->context, raw, packet, len); + if (status != DC_STATUS_SUCCESS) + return status; + } + } - if (!dc_buffer_append (raw, packet + MDS_HEADER_SIZE, chunk_size)) { - ERROR (abstract->context, "Failed to allocate memory."); - return DC_STATUS_NOMEMORY; + if (nframes >= MAX_CHUNKS) + WARNING (abstract->context, "Stream for %s hit the %u-frame guard; the dive may be truncated.", path, MAX_CHUNKS); + + // 4. Close the stream. Without STREAM_STOP the watch keeps the stream open + // on its handle, so the next GET can be answered by a leftover chunk or + // refused with 423. The watch answers with STREAM_END; a trailing chunk + // or two can still arrive first and belongs to this dive. + unsigned char stop[32]; + unsigned int stop_len = 0; + if (suunto_nautic_build_stream_fetch (stop, sizeof (stop), &stop_len, watch_magic + 3, + RPC_OP_STREAM_STOP, suunto_nautic_fetch2_tail, sizeof (suunto_nautic_fetch2_tail)) == DC_STATUS_SUCCESS && + dc_iostream_write (device->iostream, stop, stop_len, NULL) == DC_STATUS_SUCCESS) { + for (unsigned int i = 0; i < 16; i++) { + unsigned char packet[MAX_PACKET] = {0}; + size_t len = 0; + if (dc_iostream_read (device->iostream, packet, sizeof (packet), &len) != DC_STATUS_SUCCESS || len == 0) + break; + if (len >= 2 && packet[0] == 0xA5 && packet[1] == RPC_OP_STREAM_END) + break; + if (len >= 2 && packet[0] == 0xA5 && packet[1] == RPC_OP_STREAM_CHUNK) { + status = suunto_nautic_append_chunk (abstract->context, raw, packet, len); + if (status != DC_STATUS_SUCCESS) + return status; } } + } else { + WARNING (abstract->context, "Failed to send the stream stop for %s.", path); } - return DC_STATUS_SUCCESS; -} - -// A well-formed RPC frame from the watch starts with the 0xA5 magic and is long -// enough to carry an opcode. Anything shorter is line noise or a truncated read. -static int -suunto_nautic_frame_wellformed (const unsigned char *packet, size_t len) -{ - return len >= 2 && packet[0] == 0xA5; -} + status = dc_iostream_set_timeout (device->iostream, 5000); + if (status != DC_STATUS_SUCCESS) { + ERROR (abstract->context, "Failed to restore the timeout."); + return status; + } -// The DATA (0x05) frame a fetch is waiting for: our opcode, carrying the 3-byte -// session handle this fetch was issued against. -static int -suunto_nautic_frame_is_our_data (const unsigned char *packet, size_t len, const unsigned char handle[3]) -{ - return len >= RPC_HANDLE_OFFSET + 3 && - packet[0] == 0xA5 && packet[1] == RPC_OP_DATA && - memcmp (packet + RPC_HANDLE_OFFSET, handle, 3) == 0; + return DC_STATUS_SUCCESS; } -// A single BLE link is shared: another client (most often the official Suunto -// app holding a live logbook subscription) can flood it with its own frames -- -// notably Whiteboard 0x07 subscribe-result traffic on a different handle. Those -// are well-formed frames that just aren't ours, so skip them freely rather than -// treating them as a data error. This cap only guards against an unbounded loop; -// in practice the read below times out first (a clean DC_STATUS_TIMEOUT) when -// our DATA frame never gets a turn on the contended link. -#define MAX_FOREIGN_SKIPS 256 -// A tight cap on genuinely malformed/truncated frames: those do signal a real -// data-format problem, so give up quickly with DC_STATUS_DATAFORMAT. -#define MAX_MALFORMED_SKIPS 8 - -// 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]. +// GET `path` and return the 3-byte session handle from its ACK; the fetches +// that read the resource are issued against that handle. static dc_status_t -suunto_nautic_device_paginated_fetch (dc_device_t *abstract, const char *path, dc_buffer_t *response) +suunto_nautic_get_handle (dc_device_t *abstract, const char *path, unsigned char handle[3]) { - 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); + dc_status_t 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); @@ -657,10 +769,101 @@ suunto_nautic_device_paginated_fetch (dc_device_t *abstract, const char *path, d 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)); + memcpy (handle, ack_data + RPC_HANDLE_OFFSET, 3); dc_buffer_free (ack); + return DC_STATUS_SUCCESS; +} + +// Send a fetch frame, then read until the DATA (0x05) frame for `handle` +// arrives. The watch multiplexes unsolicited frames (a re-sent Hello, an +// analytics stream) on the link, and another client (the Suunto app's logbook +// subscription) can flood it, so anything else is skipped rather than spliced +// in. With skip_non_data == 0 the first frame is returned as-is (raw capture). +static dc_status_t +suunto_nautic_fetch_frame (dc_device_t *abstract, const char *path, const unsigned char fetch[], unsigned int fetch_len, + const unsigned char handle[3], int skip_non_data, unsigned char packet[MAX_PACKET], size_t *out_len) +{ + suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract; + + dc_status_t status = dc_iostream_write (device->iostream, fetch, fetch_len, NULL); + if (status != DC_STATUS_SUCCESS) { + ERROR (abstract->context, "Failed to send the fetch for %s.", path); + return status; + } + + size_t len = 0; + unsigned int foreign_skips = 0; + unsigned int malformed_skips = 0; + for (;;) { + status = dc_iostream_read (device->iostream, packet, MAX_PACKET, &len); + if (status != DC_STATUS_SUCCESS) { + // A contended link means our DATA never gets a turn and this read + // times out; surface DC_STATUS_TIMEOUT rather than DATAFORMAT. + 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; + if (suunto_nautic_frame_is_our_data (packet, len, handle)) + break; + if (suunto_nautic_frame_wellformed (packet, len)) { + if (++foreign_skips >= MAX_FOREIGN_SKIPS) { + ERROR (abstract->context, "Link saturated by another client while fetching %s; giving up.", path); + return DC_STATUS_TIMEOUT; + } + WARNING (abstract->context, "Skipping frame from another client while fetching %s (op 0x%02x).", + path, packet[1]); + continue; + } + if (++malformed_skips >= MAX_MALFORMED_SKIPS) { + ERROR (abstract->context, "Too many malformed frames while fetching %s (" DC_PRINTF_SIZE " bytes).", path, len); + return DC_STATUS_DATAFORMAT; + } + WARNING (abstract->context, "Skipping malformed frame while fetching %s (" DC_PRINTF_SIZE " bytes).", path, len); + } + + *out_len = len; + return DC_STATUS_SUCCESS; +} + +// Status of a DATA frame: 200 (complete / last page) or 100 (more pages). +static dc_status_t +suunto_nautic_data_status (dc_device_t *abstract, const char *path, const unsigned char *packet, size_t len, unsigned int *out) +{ + 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); + return DC_STATUS_DATAFORMAT; + } + + 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); + return DC_STATUS_PROTOCOL; + } + + *out = frame_status; + 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). Each DATA frame is +// [A5 05 sublen:2][msgid:2][handle:3][flags:3][status:2] followed by a page +// [header:11][data][crc:4]; only the data is kept, and the next offset +// advances by the data length (the watch's offset counts data bytes only). +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; + + unsigned char handle[3]; + status = suunto_nautic_get_handle (abstract, path, handle); + if (status != DC_STATUS_SUCCESS) + return status; + dc_buffer_clear (response); unsigned int offset = 0; const unsigned int header = 4 + 10; // A5 05 sublen(2) + 10-byte REST sub-header @@ -668,122 +871,73 @@ suunto_nautic_device_paginated_fetch (dc_device_t *abstract, const char *path, d 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); + status = suunto_nautic_build_param_fetch (fetch, sizeof (fetch), &fetch_len, + device->sequence, handle, RPC_PARAM_INT32, 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, and another client (the Suunto app's logbook - // subscription) can flood it with foreign frames. Accept only a DATA - // (0x05) frame whose handle matches this fetch; skip anything else so a - // foreign frame isn't spliced into the paginated stream. unsigned char packet[MAX_PACKET] = {0}; size_t len = 0; - unsigned int foreign_skips = 0; - unsigned int malformed_skips = 0; - for (;;) { - status = dc_iostream_read (device->iostream, packet, sizeof (packet), &len); - if (status != DC_STATUS_SUCCESS) { - // A contended link (another client holding a subscription) means - // our DATA never gets a turn and this read times out. Surface it - // as-is (DC_STATUS_TIMEOUT) rather than a misleading DATAFORMAT. - 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 (suunto_nautic_frame_is_our_data (packet, len, handle)) - break; - if (suunto_nautic_frame_wellformed (packet, len)) { - if (++foreign_skips >= MAX_FOREIGN_SKIPS) { - ERROR (abstract->context, "Link saturated by another client while paging %s; giving up.", path); - return DC_STATUS_TIMEOUT; - } - WARNING (abstract->context, "Skipping frame from another client while paging %s (op 0x%02x).", - path, packet[1]); - continue; - } - if (++malformed_skips >= MAX_MALFORMED_SKIPS) { - ERROR (abstract->context, "Too many malformed frames for %s page %u (" DC_PRINTF_SIZE " bytes).", path, page, len); - return DC_STATUS_DATAFORMAT; - } - WARNING (abstract->context, "Skipping malformed frame while paging %s (" DC_PRINTF_SIZE " bytes).", path, len); + status = suunto_nautic_fetch_frame (abstract, path, fetch, fetch_len, handle, 1, packet, &len); + if (status != DC_STATUS_SUCCESS) + return status; + + unsigned int frame_status = 0; + status = suunto_nautic_data_status (abstract, path, packet, len, &frame_status); + if (status != DC_STATUS_SUCCESS) + return status; + + if (len < header + SUMMARY_PAGE_HEADER_SIZE + RPC_CRC_SIZE) { + ERROR (abstract->context, "Page %u of %s too short for its header (" DC_PRINTF_SIZE " bytes).", page, path, len); + return DC_STATUS_DATAFORMAT; } - 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; + const unsigned char *page_data = packet + header + SUMMARY_PAGE_HEADER_SIZE; + unsigned int available = (unsigned int) (len - header - SUMMARY_PAGE_HEADER_SIZE - RPC_CRC_SIZE); + unsigned int length = array_uint16_le (packet + header + SUMMARY_PAGE_LENGTH_OFFSET); + if (length != available) { + WARNING (abstract->context, "Page %u of %s: header length %u, frame holds %u.", page, path, length, available); + if (length > available) + length = available; } - 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 (!dc_buffer_append (response, page_data, length)) { + ERROR (abstract->context, "Failed to allocate memory."); + return DC_STATUS_NOMEMORY; } + offset += length; 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; } + + if (length == 0) { + ERROR (abstract->context, "Empty page %u of %s with more pages pending.", page, path); + return DC_STATUS_PROTOCOL; + } } 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; the -// raw diagnostic (suunto_nautic_device_fetch_raw) returns the frame as-is so a -// tester can export whatever the watch actually sent when parsing fails. +// GET -> ACK(handle) -> 0x0D short fetch (no range) -> one DATA frame, returned +// whole in `frame`. The listing endpoints don't answer the 0x0B/0x10 stream +// triggers, only this form. With skip_non_data == 0 the very first frame is +// returned, whatever it is, so a tester can export what the watch sent. 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); + dc_status_t status = suunto_nautic_get_handle (abstract, path, handle); + if (status != DC_STATUS_SUCCESS) + return status; - // 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); @@ -791,58 +945,11 @@ suunto_nautic_short_fetch_frame (dc_device_t *abstract, const char *path, dc_buf 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 foreign_skips = 0; - unsigned int malformed_skips = 0; - for (;;) { - status = dc_iostream_read (device->iostream, packet, sizeof (packet), &len); - if (status != DC_STATUS_SUCCESS) { - // A contended link (another client holding a subscription) means our - // DATA never gets a turn and this read times out. Surface it as-is - // (DC_STATUS_TIMEOUT) rather than a misleading DATAFORMAT. - 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, an analytics/event stream), and another client (the - // Suunto app's logbook subscription: 0x07 subscribe-result traffic on a - // different handle) can flood the shared link. 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. - if (suunto_nautic_frame_is_our_data (packet, len, handle)) - break; - if (suunto_nautic_frame_wellformed (packet, len)) { - if (++foreign_skips >= MAX_FOREIGN_SKIPS) { - ERROR (abstract->context, "Link saturated by another client while fetching %s; giving up.", path); - return DC_STATUS_TIMEOUT; - } - WARNING (abstract->context, "Skipping frame from another client while fetching %s (op 0x%02x).", - path, packet[1]); - continue; - } - if (++malformed_skips >= MAX_MALFORMED_SKIPS) { - ERROR (abstract->context, "Too many malformed frames while fetching %s (" DC_PRINTF_SIZE " bytes).", path, len); - return DC_STATUS_DATAFORMAT; - } - WARNING (abstract->context, "Skipping malformed frame while fetching %s (" DC_PRINTF_SIZE " bytes).", path, len); - } + status = suunto_nautic_fetch_frame (abstract, path, fetch, fetch_len, handle, skip_non_data, packet, &len); + if (status != DC_STATUS_SUCCESS) + return status; dc_buffer_clear (frame); if (!dc_buffer_append (frame, packet, len)) { @@ -853,6 +960,9 @@ suunto_nautic_short_fetch_frame (dc_device_t *abstract, const char *path, dc_buf return DC_STATUS_SUCCESS; } +// Fetch a small whole resource and return its content (everything after +// A5 05 sublen). A status-100 answer is only its first page; /Logbook/Entries +// is paged by suunto_nautic_device_list_entries() instead. static dc_status_t suunto_nautic_device_short_fetch (dc_device_t *abstract, const char *path, dc_buffer_t *response) { @@ -869,25 +979,13 @@ suunto_nautic_device_short_fetch (dc_device_t *abstract, const char *path, dc_bu 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); + unsigned int frame_status = 0; + status = suunto_nautic_data_status (abstract, path, packet, len, &frame_status); + if (status != DC_STATUS_SUCCESS) { dc_buffer_free (frame); - return DC_STATUS_PROTOCOL; + return status; } - // 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); @@ -914,16 +1012,9 @@ suunto_nautic_device_fetch_raw (dc_device_t *abstract, const char *path, dc_buff if (abstract == NULL || abstract->vtable->type != DC_FAMILY_SUUNTO_NAUTIC || path == NULL || response == NULL) return DC_STATUS_INVALIDARGS; - // Diagnostic: return the raw DATA frame exactly as the watch sends it - // (the whole A5 05 .. packet, no opcode/status validation), so a tester - // can export whatever came back even when parsing fails. This mirrors the - // real listing path: /Logbook/Entries answers only the SHORT (no-range) - // 0x0D fetch and replies with a single complete status-200 frame. The - // ranged/paginated fetch is for /Summary only -- /Entries rejects it, which - // is why routing this diagnostic through paginated_fetch returned a - // non-100/200 status (DC_STATUS_PROTOCOL). The earlier "size varied per - // tap" was the Suunto app running concurrently and racing on the BLE link; - // with that app closed the short fetch returns a clean, complete frame. + // Diagnostic: the whole first DATA frame of the short fetch, without + // status validation. The ranged form is for /Summary only; /Entries + // rejects it. return suunto_nautic_short_fetch_frame (abstract, path, response, 1); } @@ -944,79 +1035,57 @@ suunto_nautic_device_download_summary (dc_device_t *abstract, const char *logboo return suunto_nautic_device_paginated_fetch (abstract, path, summary); } -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; +// Dive ids and their listed sizes from /Logbook/Entries. +typedef struct suunto_nautic_entries_t { + unsigned int *ids; + unsigned int *sizes; + unsigned int count; + unsigned int capacity; +} suunto_nautic_entries_t; - 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; - } +static void +suunto_nautic_entries_free (suunto_nautic_entries_t *entries) +{ + free (entries->ids); + free (entries->sizes); + memset (entries, 0, sizeof (*entries)); +} - 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; +static int +suunto_nautic_entries_add (suunto_nautic_entries_t *entries, unsigned int id, unsigned int size) +{ + for (unsigned int i = 0; i < entries->count; i++) { + if (entries->ids[i] == id) + return 1; } - 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); + if (entries->count == entries->capacity) { + unsigned int capacity = entries->capacity ? entries->capacity * 2 : 32; + unsigned int *ids = (unsigned int *) realloc (entries->ids, capacity * sizeof (unsigned int)); + if (ids == NULL) + return 0; + entries->ids = ids; + unsigned int *sizes = (unsigned int *) realloc (entries->sizes, capacity * sizeof (unsigned int)); + if (sizes == NULL) + return 0; + entries->sizes = sizes; + entries->capacity = capacity; } - return DC_STATUS_SUCCESS; + entries->ids[entries->count] = id; + entries->sizes[entries->count] = size; + entries->count++; + return 1; } -// 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. Both device_foreach and suunto_nautic_device_list -// share this one entry parser. -unsigned int -suunto_nautic_extract_entry_ids (const unsigned char *data, size_t size, - unsigned int *ids, unsigned int max_ids) +// Scan one /Logbook/Entries page for (start, end) pairs, in wire order. Each +// record is [start][end][w1][w2][size][pad]: start and end are 4-aligned LE +// uint32s in the dive-ID window, end within a day after start. A lone in-range +// value with no paired end is a header field (the response's own "current +// time"), not a dive. `sizes` may be NULL; a size past the buffer reads as 0. +static unsigned int +suunto_nautic_scan_entries (const unsigned char *data, size_t size, + unsigned int *ids, unsigned int *sizes, unsigned int max_ids) { unsigned int count = 0; for (size_t i = 0; i + 8 <= size && count < max_ids; i += 4) { @@ -1026,10 +1095,21 @@ suunto_nautic_extract_entry_ids (const unsigned char *data, size_t size, 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 + if (sizes) + sizes[count] = i + ENTRY_SIZE_OFFSET + 4 <= size ? array_uint32_le (data + i + ENTRY_SIZE_OFFSET) : 0; + ids[count++] = v; + i += 4; // skip the paired end timestamp } } + return count; +} + +// Extract dive-start ids from a /Logbook/Entries page, newest-first. +unsigned int +suunto_nautic_extract_entry_ids (const unsigned char *data, size_t size, + unsigned int *ids, unsigned int max_ids) +{ + unsigned int count = suunto_nautic_scan_entries (data, size, ids, NULL, max_ids); // Sort descending (newest first); insertion sort is fine at logbook scale. for (unsigned int a = 1; a < count; a++) { unsigned int key = ids[a]; @@ -1040,52 +1120,244 @@ suunto_nautic_extract_entry_ids (const unsigned char *data, size_t size, return count; } +// List every dive on the watch, newest-first, with its listed size. The list +// is served oldest-first in pages: a status-100 page is followed by a fetch +// with StartAfterId = the last id received, until a status-200 page. Taking +// page 1 alone drops the newest dives. +static dc_status_t +suunto_nautic_device_list_entries (dc_device_t *abstract, suunto_nautic_entries_t *entries) +{ + suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract; + const char *path = "/Logbook/Entries"; + + memset (entries, 0, sizeof (*entries)); + + unsigned char handle[3]; + dc_status_t status = suunto_nautic_get_handle (abstract, path, handle); + if (status != DC_STATUS_SUCCESS) + return status; + + unsigned int start_after = 0; + for (unsigned int page = 0; page < MAX_PAGES; page++) { + unsigned char fetch[32]; + unsigned int fetch_len = 0; + if (page == 0) + status = suunto_nautic_build_short_fetch (fetch, sizeof (fetch), &fetch_len, device->sequence, handle); + else + status = suunto_nautic_build_param_fetch (fetch, sizeof (fetch), &fetch_len, + device->sequence, handle, RPC_PARAM_UINT32, start_after); + if (status != DC_STATUS_SUCCESS) + goto error; + device->sequence++; + + unsigned char packet[MAX_PACKET] = {0}; + size_t len = 0; + status = suunto_nautic_fetch_frame (abstract, path, fetch, fetch_len, handle, 1, packet, &len); + if (status != DC_STATUS_SUCCESS) + goto error; + + unsigned int frame_status = 0; + status = suunto_nautic_data_status (abstract, path, packet, len, &frame_status); + if (status != DC_STATUS_SUCCESS) + goto error; + + unsigned int ids[MAX_PACKET / 8]; + unsigned int sizes[MAX_PACKET / 8]; + unsigned int count = suunto_nautic_scan_entries (packet + 4, len - 4, ids, sizes, MAX_PACKET / 8); + for (unsigned int i = 0; i < count; i++) { + if (!suunto_nautic_entries_add (entries, ids[i], sizes[i])) { + ERROR (abstract->context, "Failed to allocate memory."); + status = DC_STATUS_NOMEMORY; + goto error; + } + } + + if (frame_status == RPC_STATUS_OK) + break; + + if (count == 0 || ids[count - 1] == start_after) { + WARNING (abstract->context, "%s page %u has no new entries; stopping the listing.", path, page + 1); + break; + } + start_after = ids[count - 1]; + + if (page + 1 == MAX_PAGES) + WARNING (abstract->context, "%s hit the page limit; the newest dives may be missing.", path); + } + + // Sort descending (newest first), keeping each size with its id. + for (unsigned int a = 1; a < entries->count; a++) { + unsigned int id = entries->ids[a], size = entries->sizes[a]; + int b = (int) a - 1; + while (b >= 0 && entries->ids[b] < id) { + entries->ids[b + 1] = entries->ids[b]; + entries->sizes[b + 1] = entries->sizes[b]; + b--; + } + entries->ids[b + 1] = id; + entries->sizes[b + 1] = size; + } + + return DC_STATUS_SUCCESS; + +error: + suunto_nautic_entries_free (entries); + return status; +} + // Public: list dive ids (each a UNIX-timestamp LogId) newest-first, without // downloading the dives. Writes the ids as packed little-endian uint32 into // `out`; the caller reads them as a plain array (no protocol knowledge needed). dc_status_t suunto_nautic_device_list (dc_device_t *abstract, dc_buffer_t *out) { - if (out == NULL) + if (abstract == NULL || abstract->vtable->type != DC_FAMILY_SUUNTO_NAUTIC || out == NULL) return DC_STATUS_INVALIDARGS; - dc_buffer_t *entries = dc_buffer_new (0); - if (entries == NULL) - return DC_STATUS_NOMEMORY; - - dc_status_t status = suunto_nautic_device_short_fetch (abstract, "/Logbook/Entries", entries); - if (status != DC_STATUS_SUCCESS) { - dc_buffer_free (entries); + suunto_nautic_entries_t entries; + dc_status_t status = suunto_nautic_device_list_entries (abstract, &entries); + if (status != DC_STATUS_SUCCESS) return status; - } - - const unsigned char *data = dc_buffer_get_data (entries); - size_t size = dc_buffer_get_size (entries); - size_t max_ids = size / 4 + 1; - 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 (data, size, ids, (unsigned int) max_ids); - dc_buffer_free (entries); dc_buffer_clear (out); - for (unsigned int i = 0; i < count; i++) { + for (unsigned int i = 0; i < entries.count; i++) { unsigned char le[4]; - le[0] = ids[i] & 0xFF; - le[1] = (ids[i] >> 8) & 0xFF; - le[2] = (ids[i] >> 16) & 0xFF; - le[3] = (ids[i] >> 24) & 0xFF; + array_uint32_le_set (le, entries.ids[i]); if (!dc_buffer_append (out, le, sizeof (le))) { - free (ids); + suunto_nautic_entries_free (&entries); return DC_STATUS_NOMEMORY; } } - free (ids); + + suunto_nautic_entries_free (&entries); return DC_STATUS_SUCCESS; } +// Download one dive: the compressed /Data stream (retried while the watch +// refuses it), decompressed, with the /Summary appended. When `listed_size` +// is known (non-zero) the download is checked against it: the listed size is +// compressed /Data + /Summary data bytes, exactly. A mismatch is retried once +// and then reported through `*incomplete`, with the dive left in `raw`. +static dc_status_t +suunto_nautic_device_download_dive (dc_device_t *abstract, const char *logbook_id, unsigned int listed_size, + dc_buffer_t *raw, int *incomplete) +{ + suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract; + dc_status_t status = DC_STATUS_SUCCESS; + + *incomplete = 0; + + 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); + dc_buffer_t *summary = dc_buffer_new (0); + if (compressed == NULL || summary == NULL) { + status = DC_STATUS_NOMEMORY; + goto done; + } + + for (unsigned int attempt = 0; attempt < DOWNLOAD_ATTEMPTS; attempt++) { + for (unsigned int s = 0; s < STREAM_ATTEMPTS; s++) { + if (s > 0) { + WARNING (abstract->context, "Retrying the stream of %s (attempt %u/%u).", logbook_id, s + 1, STREAM_ATTEMPTS); + dc_iostream_sleep (device->iostream, 1500 * s); + } + dc_buffer_clear (compressed); + status = suunto_nautic_device_stream_fetch (abstract, path, compressed); + if (status != DC_STATUS_PROTOCOL) + break; + } + if (status != DC_STATUS_SUCCESS) + goto done; + + DEBUG (abstract->context, "Captured " DC_PRINTF_SIZE " compressed bytes for logbook entry %s.", + dc_buffer_get_size (compressed), logbook_id); + + // Best-effort: the profile alone is still a valid dive without it. + size_t summary_size = 0; + if (suunto_nautic_device_download_summary (abstract, logbook_id, summary) == DC_STATUS_SUCCESS) + summary_size = dc_buffer_get_size (summary); + else { + dc_buffer_clear (summary); + WARNING (abstract->context, "Failed to fetch the Summary for %s; GF/gas will be unavailable.", logbook_id); + } + + if (listed_size == 0) + break; + + size_t data_size = dc_buffer_get_size (compressed); + if (summary_size) + *incomplete = data_size + summary_size != listed_size; + else + *incomplete = data_size > listed_size || listed_size - data_size > SUMMARY_MAX_SIZE; + if (!*incomplete) + break; + + WARNING (abstract->context, "Incomplete download of %s: /Data " DC_PRINTF_SIZE " + /Summary " DC_PRINTF_SIZE + " bytes, listed %u (attempt %u/%u).", logbook_id, data_size, summary_size, listed_size, + attempt + 1, DOWNLOAD_ATTEMPTS); + } + + status = suunto_nautic_heatshrink_decompress (abstract->context, + dc_buffer_get_data (compressed), dc_buffer_get_size (compressed), raw); + if (status != DC_STATUS_SUCCESS) { + ERROR (abstract->context, "Failed to decompress the logbook entry."); + goto done; + } + + 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."); + status = DC_STATUS_DATAFORMAT; + goto done; + } + + DEBUG (abstract->context, "Decompressed " DC_PRINTF_SIZE " bytes for logbook entry %s.", + dc_buffer_get_size (raw), logbook_id); + + // The /Summary SBEM (gradient factors, gas mix) goes after the profile + // so the parser can expose them; they aren't in the profile stream. + if (dc_buffer_get_size (summary) && + !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."); + +done: + if (status != DC_STATUS_SUCCESS) + dc_buffer_clear (raw); + dc_buffer_free (summary); + dc_buffer_free (compressed); + return status; +} + +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; + + // Look up the listed size to verify against; best-effort, since an + // unlisted or unlistable dive can still be downloaded. + unsigned int listed_size = 0; + suunto_nautic_entries_t entries; + if (suunto_nautic_device_list_entries (abstract, &entries) == DC_STATUS_SUCCESS) { + unsigned long id = strtoul (logbook_id, NULL, 10); + for (unsigned int i = 0; i < entries.count; i++) { + if (entries.ids[i] == id) + listed_size = entries.sizes[i]; + } + suunto_nautic_entries_free (&entries); + } + + int incomplete = 0; + dc_status_t status = suunto_nautic_device_download_dive (abstract, logbook_id, listed_size, raw, &incomplete); + if (status == DC_STATUS_SUCCESS && incomplete) + return DC_STATUS_DATAFORMAT; + return status; +} + // True for a token that is exactly "..", each part // < 256; on success fills a/b/c. static int @@ -1231,51 +1503,27 @@ suunto_nautic_device_foreach (dc_device_t *abstract, dc_dive_callback_t callback 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); + // Every listed dive, newest-first, with its listed size (all pages). + suunto_nautic_entries_t entries; + status = suunto_nautic_device_list_entries (abstract, &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); + unsigned int count = entries.count; 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); + suunto_nautic_entries_free (&entries); 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), - }; + unsigned char fingerprint[4]; + array_uint32_le_set (fingerprint, entries.ids[i]); // Walking newest-first, so the first fingerprint match means // everything from here on was already downloaded in a @@ -1284,12 +1532,13 @@ suunto_nautic_device_foreach (dc_device_t *abstract, dc_dive_callback_t callback break; char logbook_id[16]; - int n = snprintf (logbook_id, sizeof (logbook_id), "%u", ids[i]); + int n = snprintf (logbook_id, sizeof (logbook_id), "%u", entries.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); + int incomplete = 0; + status = suunto_nautic_device_download_dive (abstract, logbook_id, entries.sizes[i], raw, &incomplete); 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 @@ -1300,6 +1549,11 @@ suunto_nautic_device_foreach (dc_device_t *abstract, dc_dive_callback_t callback continue; } + // Delivered anyway: skipping it would move the fingerprint past a + // dive that a later sync could never fetch again. + if (incomplete) + WARNING (abstract->context, "Logbook entry %s does not match its listed size; the dive may be truncated.", logbook_id); + progress.current += 2; device_event_emit (abstract, DC_EVENT_PROGRESS, &progress); @@ -1309,7 +1563,7 @@ suunto_nautic_device_foreach (dc_device_t *abstract, dc_dive_callback_t callback } dc_buffer_free (raw); - free (ids); + suunto_nautic_entries_free (&entries); return DC_STATUS_SUCCESS; } diff --git a/src/suunto_nautic.h b/src/suunto_nautic.h index 2122c50..a8f77d3 100644 --- a/src/suunto_nautic.h +++ b/src/suunto_nautic.h @@ -36,7 +36,7 @@ * - 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. + * silence timeout, then closes it with STREAM_STOP (opcode 0x11). * - 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 @@ -60,8 +60,12 @@ * - 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 + * UNIX timestamp, from the SBEM payload. The list is served + * oldest-first in pages (status 100 = more); later pages are fetched + * with a uint32 StartAfterId parameter. Dives are downloaded * newest-first, stopping at the first id matching the fingerprint. + * - Each entry's listed size equals the compressed /Data plus the + * /Summary data bytes, which verifies a download is complete. * * Datetime: * - The stream carries no wall-clock timestamp except in GPS fixes