From a8f60178c5b0d5511cf14ad26506289f6bac0736 Mon Sep 17 00:00:00 2001 From: Latisha Date: Fri, 16 Oct 2026 12:03:04 +0800 Subject: [PATCH] suunto_nautic: send and receive packets through one layer, and verify them The transport from the upstream PR (libdivecomputer/libdivecomputer#73), reworked after Jef's review: every reply is checked (start byte, length, CRC) and matched by opcode and sequence number, so the replies to the Suunto app's requests on the same link are skipped instead of taken as ours. The /Data stream ends on its end marker with each chunk's offset checked, and /Logbook/Entries is parsed by its records. The extension API is rebuilt on the same layer, with the same outputs: request/fetch/fetch_raw return the reply packets as before. Unlike upstream, the open carries on without a reply to the hello and flags it, so the apps can tell the diver the watch may be asleep, and the sequence numbers carry on from the previous connection. --- src/suunto_nautic.c | 2261 +++++++++++++++++++------------------------ src/suunto_nautic.h | 45 +- 2 files changed, 1025 insertions(+), 1281 deletions(-) diff --git a/src/suunto_nautic.c b/src/suunto_nautic.c index f79443f..9dcb78f 100644 --- a/src/suunto_nautic.c +++ b/src/suunto_nautic.c @@ -22,9 +22,9 @@ #include #include #include -#include #include "suunto_nautic.h" +#include #include "context-private.h" #include "device-private.h" #include "platform.h" @@ -33,110 +33,89 @@ #include "hdlc.h" #include "heatshrink/heatshrink_decoder.h" +#include + // See suunto_nautic.h for a description of the transport and format. -#define RPC_OP_GET 0x0A -#define RPC_OP_RELEASE 0x0B // host -> watch: release a handle a GET resolved (answered by 0x03) -#define RPC_OP_FETCH 0x0D -#define RPC_OP_STREAM_FETCH2 0x10 -#define RPC_OP_STREAM_STOP 0x11 // host -> watch: close the stream -#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) -#define RPC_OP_HELLO_ACK 0x13 // watch -> host: answers the EVA (Hello, 0x12) handshake - -// The /Data "stream" is a Whiteboard subscription: 0x10 subscribes to a -// resource handle (answered by 0x08 with the current value), the watch then -// pushes 0x01 notifications on that handle, and 0x11 unsubscribes (answered by -// 0x09). Value resources such as /Logbook/UnsynchronisedLogs work the same way. -#define RPC_OP_SUBSCRIBE RPC_OP_STREAM_FETCH2 -#define RPC_OP_UNSUBSCRIBE RPC_OP_STREAM_STOP -#define RPC_OP_SUBSCRIBE_REPLY RPC_OP_STREAM_START -#define RPC_OP_UNSUBSCRIBE_REPLY RPC_OP_STREAM_END -#define RPC_OP_NOTIFICATION RPC_OP_STREAM_CHUNK +#define RPC_OP_NOTIFICATION 0x01 // watch -> host: one chunk of a subscribed stream +#define RPC_OP_GET_REPLY 0x02 +#define RPC_OP_RELEASE_REPLY 0x03 +#define RPC_OP_DATA 0x05 // watch -> host: answers FETCH +#define RPC_OP_SUBSCRIBE_REPLY 0x08 +#define RPC_OP_UNSUBSCRIBE_REPLY 0x09 +#define RPC_OP_GET 0x0A // host -> watch: resolve a path to a handle +#define RPC_OP_RELEASE 0x0B // host -> watch: release a handle bound by a GET +#define RPC_OP_FETCH 0x0D +#define RPC_OP_SUBSCRIBE 0x10 +#define RPC_OP_UNSUBSCRIBE 0x11 +#define RPC_OP_HELLO 0x12 +#define RPC_OP_HELLO_REPLY 0x13 + +// Packet: 0xA5 OPCODE LENGTH(2) SEQNUM(2) PAYLOAD(LENGTH) CRC32(4) +#define RPC_START 0xA5 +#define RPC_OVERHEAD 10 +#define MAX_PAYLOAD 512 +#define MAX_PATH 240 + +// HTTP-like status codes carried in the replies. +#define STATUS_OK 200 +#define STATUS_CONTINUE 100 // more pages follow // A GET of a per-id path (/Logbook/byId//...) binds the id to one of the -// resource's handle slots, [00|10 24 ], until a RELEASE frees it; a -// second GET while the 00 slot is still bound gets the 10 slot. Static paths, -// the value resources included (/Logbook/UnsynchronisedLogs = F0 24 0D, -// /Sync/BusyState = F0 4C 00 in the Suunto app captures), answer with an F0 -// handle and the unknown-path handle is FF FF FF; neither is released. -#define RPC_HANDLE_SLOT 0x00 -#define RPC_HANDLE_SLOT_ALT 0x10 -#define RPC_HANDLE_STATIC 0xF0 -#define RPC_HANDLE_UNKNOWN 0xFF - -// 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 -// 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 - -// 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 - -// A /Summary is ~2-2.4 KB; bounds the listed-size check when it is missing. +// resource's handle slots, [00|10] 24 , until a RELEASE frees it. A +// second GET while the 00 slot is still bound gets the 10 slot. Static paths +// answer with an F0 handle and an unknown path with FF FF FF; neither is +// released. The slot is the first byte on the wire. +#define HANDLE_SLOT(handle) ((handle) & 0xFF) +#define SLOT_DEFAULT 0x00 +#define SLOT_ALT 0x10 +#define SLOT_STATIC 0xF0 +#define SLOT_UNKNOWN 0xFF + +// Fetch parameter types: the /Summary page offset is an int32, the +// /Logbook/Entries start id a uint32. +#define PARAM_INT32 0x06 +#define PARAM_UINT32 0x07 + +// Reply payloads start with HANDLE(3) FLAGS(3) STATUS(2); a DATA reply then +// carries the content. +#define REPLY_STATUS 6 +#define REPLY_CONTENT 8 + +// Stream notification payload: +// HANDLE(3) FLAGS(3) UNKNOWN(4) OFFSET(4) LENGTH(2) UNKNOWN(6) DATA(LENGTH) +// OFFSET is the position of the chunk in the stream. The last chunk has a +// zero length, at an offset equal to the total size. +#define CHUNK_OFFSET 10 +#define CHUNK_LENGTH 14 +#define CHUNK_DATA 22 + +// A /Summary page is HEADER(11) DATA, with the data length at offset 3 of +// the header. The paging offset counts data bytes only. +#define PAGE_HEADER 11 +#define PAGE_LENGTH 3 + +// /Logbook/Entries is HEADER(18) RECORD(24) x COUNT, with the count at offset +// 6 of the header. A record is START(4) END(4) UNKNOWN(8) SIZE(4) UNKNOWN(4): +// the start time is the dive id, and the size is the compressed /Data plus +// the /Summary data bytes. An empty logbook can omit the end of the header. +#define ENTRIES_HEADER 18 +#define ENTRIES_COUNT 6 +#define ENTRY_SIZE 24 +#define ENTRY_START 0 +#define ENTRY_LENGTH 16 + +// A /Summary is about 2.3 KB; bounds the 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, -// /Logbook/Entries one page per ~18 dives, and a dive's Descriptors schema -// about 97). -#define MAX_PAGES 512 +// How many times to issue a dive's stream when the watch refuses it with +// status 423 (Locked), and to download a dive whose size doesn't match the +// listing. +#define STREAM_ATTEMPTS 3 +#define DOWNLOAD_ATTEMPTS 2 -// 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 +#define TIMEOUT_DEFAULT 5000 +#define TIMEOUT_STREAM 4000 // the gap between two chunks reaches about 2s // Heatshrink (LZSS) parameters used by the Nautic/Ocean's MDS stream. #define HEATSHRINK_WINDOW_SZ2 7 @@ -145,25 +124,30 @@ static const unsigned char SBEM_MAGIC[8] = {'S','B','E','M','0','1','0','3'}; -// Message ids carry on from the previous connection instead of restarting at -// 1, as the Suunto app's do: after a dropped link the watch can keep the old -// session, and a request reusing one of its recent ids goes unanswered. -static unsigned int g_suunto_nautic_next_msgid = 0; +// The sequence number to continue from on the next connection. The Suunto +// app's carry on across connections too, so a reconnect never repeats one +// the watch still holds from the dropped session. +static unsigned int g_suunto_nautic_seqnum = 0; 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 int seqnum; + // Whether the watch answered the hello (extension API). + int acknowledged; + // The dive id (a UNIX timestamp) of the most recently downloaded dive, + // little-endian. All-zero means no fingerprint. unsigned char fingerprint[4]; - // Whether the watch answered the EVA handshake; the open carries on without it. - int handshake_acknowledged; } suunto_nautic_device_t; +// Dive ids and their listed sizes, newest first. +typedef struct suunto_nautic_entries_t { + unsigned int *ids; + unsigned int *sizes; + unsigned int count; + unsigned int capacity; +} suunto_nautic_entries_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); @@ -180,6 +164,19 @@ static const dc_device_vtable_t suunto_nautic_device_vtable = { suunto_nautic_device_close, /* close */ }; +// The Hello payload: the protocol versions (9, 9), the host identity (the one +// the Suunto app sends, a packed 12-byte serial), the Whiteboard version +// (4.1.2) and capability flags. The watch answers with the same fields for +// itself. +static const unsigned char suunto_nautic_hello[] = { + 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 +}; + +static const unsigned char suunto_nautic_noparams[] = {0x00}; + static dc_status_t suunto_nautic_device_set_fingerprint (dc_device_t *abstract, const unsigned char data[], unsigned int size) { @@ -196,245 +193,153 @@ suunto_nautic_device_set_fingerprint (dc_device_t *abstract, const unsigned char 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 -}; +static unsigned int +suunto_nautic_seqnum (suunto_nautic_device_t *device) +{ + // Zero is the sequence number of the stream notifications. + device->seqnum = (device->seqnum + 1) & 0xFFFF; + if (device->seqnum == 0) + device->seqnum = 1; -#define EVA_HANDSHAKE_SIZE (sizeof (suunto_nautic_eva_handshake)) + return device->seqnum; +} -// 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) +suunto_nautic_send (suunto_nautic_device_t *device, unsigned char opcode, unsigned int seqnum, const unsigned char data[], size_t size) { - size_t pathlen = strlen (path); - if (pathlen == 0 || pathlen > MAX_PATH) - return DC_STATUS_INVALIDARGS; + dc_status_t status = DC_STATUS_SUCCESS; + unsigned char packet[RPC_OVERHEAD + MAX_PAYLOAD] = {0}; - unsigned int len = RPC_HEADER_SIZE + (unsigned int) pathlen + RPC_CRC_SIZE; - if (len > size) + if (size > MAX_PAYLOAD) return DC_STATUS_INVALIDARGS; - unsigned int sublen = (unsigned int) pathlen + 4; + packet[0] = RPC_START; + packet[1] = opcode; + array_uint16_le_set (packet + 2, size); + array_uint16_le_set (packet + 4, seqnum); + if (size) + memcpy (packet + 6, data, size); - 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, 6 + size); + array_uint32_le_set (packet + 6 + size, crc); - unsigned int crc = checksum_crc32r (packet, RPC_HEADER_SIZE + (unsigned int) pathlen); - array_uint32_le_set (packet + RPC_HEADER_SIZE + (unsigned int) pathlen, crc); + status = dc_iostream_write (device->iostream, packet, RPC_OVERHEAD + size, NULL); + if (status != DC_STATUS_SUCCESS) { + ERROR (device->base.context, "Failed to send the packet."); + return status; + } - *out_len = len; - return DC_STATUS_SUCCESS; + return status; } -// Build a RELEASE (0x0B) frame: [seq:2 LE][handle:3][01 80 00]. +// Receive the packet with the given opcode and sequence number. The watch +// serves every app connected to it over the same link, so the replies to +// another app's requests can arrive in between. Those are recognized by their +// sequence number and skipped. static dc_status_t -suunto_nautic_build_release (unsigned char packet[], unsigned int size, unsigned int *out_len, - unsigned int seq, const unsigned char handle[3]) +suunto_nautic_recv (suunto_nautic_device_t *device, unsigned char opcode, unsigned int seqnum, unsigned char data[], size_t size, size_t *actual) { - static const unsigned char tail[] = { 0x01, 0x80, 0x00 }; - unsigned int payload = 3 + (unsigned int) sizeof (tail); - unsigned int len = 4 + 2 + payload + RPC_CRC_SIZE; - if (len > size) - return DC_STATUS_INVALIDARGS; - - packet[0] = 0xA5; - packet[1] = RPC_OP_RELEASE; - 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; -} + dc_status_t status = DC_STATUS_SUCCESS; + unsigned char packet[RPC_OVERHEAD + MAX_PAYLOAD] = {0}; + size_t length = 0; -// 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_handle_op (unsigned char packet[], unsigned int size, unsigned int *out_len, - unsigned int seq, unsigned char opcode, 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) + if (size > MAX_PAYLOAD) return DC_STATUS_INVALIDARGS; - packet[0] = 0xA5; - packet[1] = opcode; - // 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); + while (1) { + status = dc_iostream_read (device->iostream, packet, sizeof(packet), &length); + if (status != DC_STATUS_SUCCESS) { + ERROR (device->base.context, "Failed to receive the packet."); + return status; + } - *out_len = len; - return DC_STATUS_SUCCESS; -} + if (length == 0) { + // Two flags in a row, e.g. after the tail of a packet that was + // cut off when an earlier link dropped. + continue; + } -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]) -{ - return suunto_nautic_build_handle_op (packet, size, out_len, seq, RPC_OP_FETCH, handle); -} + if (length < RPC_OVERHEAD) { + ERROR (device->base.context, "Unexpected packet size (" DC_PRINTF_SIZE ").", length); + return DC_STATUS_PROTOCOL; + } -// 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_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) -{ - 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; + unsigned int crc = array_uint32_le (packet + length - 4); + unsigned int ccrc = checksum_crc32r (packet, length - 4); + if (crc != ccrc) { + ERROR (device->base.context, "Unexpected packet checksum (%08x %08x).", crc, ccrc); + return DC_STATUS_PROTOCOL; + } - 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), value); + if (packet[0] != RPC_START) { + ERROR (device->base.context, "Unexpected packet start byte (%02x).", packet[0]); + return DC_STATUS_PROTOCOL; + } - unsigned int crc = checksum_crc32r (packet, 6 + payload); - array_uint32_le_set (packet + 6 + payload, crc); + unsigned int rsp_length = array_uint16_le (packet + 2); + if (rsp_length != length - RPC_OVERHEAD) { + ERROR (device->base.context, "Unexpected packet payload length (%u " DC_PRINTF_SIZE ").", rsp_length, length - RPC_OVERHEAD); + return DC_STATUS_PROTOCOL; + } - *out_len = len; - return DC_STATUS_SUCCESS; -} + unsigned int rsp_opcode = packet[1]; + unsigned int rsp_seqnum = array_uint16_le (packet + 4); + if (rsp_opcode != opcode || rsp_seqnum != seqnum) { + if (rsp_opcode == RPC_OP_NOTIFICATION) { + // A leftover chunk of a stream that was stopped. + DEBUG (device->base.context, "Skipping a stream notification."); + } else { + WARNING (device->base.context, "Skipping a packet for another request (%02x %u).", rsp_opcode, rsp_seqnum); + } + continue; + } -// 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; -} + if (rsp_length > size) { + ERROR (device->base.context, "Unexpected packet payload length (%u " DC_PRINTF_SIZE ").", rsp_length, size); + return DC_STATUS_PROTOCOL; + } -// 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; -} + memcpy (data, packet + 6, rsp_length); + *actual = rsp_length; -// 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 status; + } } -// 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 - +// Send a request on a handle and receive its reply: the payload is the handle +// and its 01 80 00 resource flags, followed by the parameters, if any. static dc_status_t -suunto_nautic_transfer (suunto_nautic_device_t *device, const unsigned char req[], unsigned int rsize, dc_buffer_t *response) +suunto_nautic_transfer (suunto_nautic_device_t *device, unsigned char opcode, unsigned char rsp_opcode, + unsigned int handle, const unsigned char params[], size_t nparams, unsigned char data[], size_t size, size_t *actual) { dc_status_t status = DC_STATUS_SUCCESS; - dc_device_t *abstract = (dc_device_t *) device; - - if (device_is_cancelled (abstract)) - return DC_STATUS_CANCELLED; + unsigned char request[6 + 7] = {0}; - 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 (nparams > sizeof(request) - 6) + return DC_STATUS_INVALIDARGS; - if (response) { - unsigned int msgid = array_uint16_le (req + RPC_MSGID_OFFSET); - 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) { - ERROR (abstract->context, "Failed to receive the RPC response."); - return status; - } + array_uint24_le_set (request, handle); + request[3] = 0x01; + request[4] = 0x80; + request[5] = 0x00; + if (nparams) + memcpy (request + 6, params, nparams); - HEXDUMP (abstract->context, DC_LOGLEVEL_DEBUG, "RPC RSP", packet, len); + unsigned int seqnum = suunto_nautic_seqnum (device); + status = suunto_nautic_send (device, opcode, seqnum, request, 6 + nparams); + if (status != DC_STATUS_SUCCESS) + return status; - 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); - } + status = suunto_nautic_recv (device, rsp_opcode, seqnum, data, size, actual); + if (status != DC_STATUS_SUCCESS) + return status; - dc_buffer_clear (response); - if (!dc_buffer_append (response, packet, len)) { - ERROR (abstract->context, "Failed to allocate memory."); - return DC_STATUS_NOMEMORY; - } + if (*actual < REPLY_CONTENT) { + ERROR (device->base.context, "Unexpected reply size (" DC_PRINTF_SIZE ").", *actual); + return DC_STATUS_PROTOCOL; } - return DC_STATUS_SUCCESS; + return status; } dc_status_t @@ -452,13 +357,14 @@ suunto_nautic_device_open (dc_device_t **out, dc_context_t *context, dc_iostream return DC_STATUS_NOMEMORY; } - if (g_suunto_nautic_next_msgid == 0) - g_suunto_nautic_next_msgid = (unsigned int) time (NULL) * 2654435761u; - device->sequence = g_suunto_nautic_next_msgid & 0xFFFF; - if (device->sequence == 0) - device->sequence = 1; + // Start at a different sequence number on every connection. After a + // dropped link the watch can still hold the previous session, and leaves a + // request unanswered that reuses one of its sequence numbers. + device->seqnum = ((unsigned int) dc_datetime_now () * 2654435761u) & 0xFFFF; + if (g_suunto_nautic_seqnum) + device->seqnum = g_suunto_nautic_seqnum; memset (device->fingerprint, 0, sizeof (device->fingerprint)); - device->handshake_acknowledged = 0; + device->acknowledged = 0; status = dc_hdlc_open (&device->iostream, context, iostream, 244, 244); if (status != DC_STATUS_SUCCESS) { @@ -466,7 +372,7 @@ suunto_nautic_device_open (dc_device_t **out, dc_context_t *context, dc_iostream goto error_free; } - status = dc_iostream_set_timeout (device->iostream, 5000); + status = dc_iostream_set_timeout (device->iostream, TIMEOUT_DEFAULT); if (status != DC_STATUS_SUCCESS) { ERROR (context, "Failed to set the timeout."); goto error_close; @@ -474,49 +380,25 @@ suunto_nautic_device_open (dc_device_t **out, dc_context_t *context, dc_iostream dc_iostream_purge (device->iostream, DC_DIRECTION_ALL); - // EVA handshake. The response content can't be validated (its format isn't - // understood), but its arrival can: the watch answers with a Hello-Ack. - 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); + status = suunto_nautic_send (device, RPC_OP_HELLO, 0, suunto_nautic_hello, sizeof (suunto_nautic_hello)); if (status != DC_STATUS_SUCCESS) { - ERROR (context, "Failed to send the EVA handshake."); + ERROR (context, "Failed to send the hello."); goto error_close; } - // On a quick reconnect the watch is still sending the tail of the stream the - // dropped link was reading, ahead of the Hello-Ack, and a request sent before - // the Ack has arrived can go unanswered. The purge above runs too early to - // catch those frames, so skip them here until the Ack turns up. If the link - // goes quiet without one, carry on as before: the stale frames are drained. - unsigned char handshake_rsp[MAX_PACKET] = {0}; - size_t handshake_len = 0; - unsigned int handshake_skips = 0; - for (;;) { - status = dc_iostream_read (device->iostream, handshake_rsp, sizeof (handshake_rsp), &handshake_len); - if (status == DC_STATUS_TIMEOUT && handshake_skips > 0) { - WARNING (context, "No EVA handshake response after %u other frames; continuing.", handshake_skips); - break; - } - 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); - - if (handshake_len >= 2 && handshake_rsp[0] == 0xA5 && handshake_rsp[1] == RPC_OP_HELLO_ACK) { - device->handshake_acknowledged = 1; - break; - } - if (++handshake_skips >= MAX_FOREIGN_SKIPS) { - ERROR (context, "No EVA handshake response among the frames on the link."); - status = DC_STATUS_TIMEOUT; - goto error_close; - } - WARNING (context, "Skipping a frame ahead of the EVA handshake response (" DC_PRINTF_SIZE " bytes).", handshake_len); + unsigned char hello[sizeof (suunto_nautic_hello)] = {0}; + size_t hello_len = 0; + status = suunto_nautic_recv (device, RPC_OP_HELLO_REPLY, 0, hello, sizeof (hello), &hello_len); + if (status == DC_STATUS_TIMEOUT) { + // A sleeping watch doesn't answer. Carry on, so the host can tell + // the diver to wake it when the first request times out. + WARNING (context, "No reply to the hello; continuing."); + status = DC_STATUS_SUCCESS; + } else if (status != DC_STATUS_SUCCESS) { + ERROR (context, "Failed to receive the hello."); + goto error_close; + } else { + device->acknowledged = 1; } *out = (dc_device_t *) device; @@ -535,41 +417,95 @@ suunto_nautic_device_close (dc_device_t *abstract) { suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract; - g_suunto_nautic_next_msgid = device->sequence | 0x10000; + g_suunto_nautic_seqnum = device->seqnum ? device->seqnum : 1; return dc_iostream_close (device->iostream); } -int -suunto_nautic_device_handshake_acknowledged (dc_device_t *abstract) +// Resolve a path to a handle. The GET reply is HANDLE(3) FLAGS(3) STATUS(2). +static dc_status_t +suunto_nautic_get (suunto_nautic_device_t *device, const char *path, unsigned int *handle, unsigned int *result) { - if (abstract == NULL || abstract->vtable->type != DC_FAMILY_SUUNTO_NAUTIC) - return 0; + dc_status_t status = DC_STATUS_SUCCESS; + + size_t pathlen = strlen (path); + if (pathlen == 0 || pathlen > MAX_PATH) + return DC_STATUS_INVALIDARGS; + + unsigned char request[4 + MAX_PATH] = {0}; + request[0] = 0x01; + request[1] = 0x80; + request[2] = 0x00; + request[3] = pathlen; + memcpy (request + 4, path, pathlen); + + unsigned int seqnum = suunto_nautic_seqnum (device); + status = suunto_nautic_send (device, RPC_OP_GET, seqnum, request, 4 + pathlen); + if (status != DC_STATUS_SUCCESS) { + ERROR (device->base.context, "Failed to send the request for %s.", path); + return status; + } + + unsigned char reply[REPLY_CONTENT] = {0}; + size_t reply_len = 0; + status = suunto_nautic_recv (device, RPC_OP_GET_REPLY, seqnum, reply, sizeof (reply), &reply_len); + if (status != DC_STATUS_SUCCESS) { + ERROR (device->base.context, "Failed to receive the reply for %s.", path); + return status; + } + + if (reply_len != sizeof (reply)) { + ERROR (device->base.context, "Unexpected reply size (" DC_PRINTF_SIZE ").", reply_len); + return DC_STATUS_PROTOCOL; + } + + *handle = array_uint24_le (reply); + *result = array_uint16_le (reply + REPLY_STATUS); - return ((suunto_nautic_device_t *) abstract)->handshake_acknowledged; + return status; } -dc_status_t -suunto_nautic_device_request (dc_device_t *abstract, const char *path, dc_buffer_t *response) +// Release a handle bound by a GET, so the next GET of the same resource binds +// its own id to the slot. A failure only leaves the slot bound. +static void +suunto_nautic_release (suunto_nautic_device_t *device, unsigned int handle) { - if (abstract == NULL || abstract->vtable->type != DC_FAMILY_SUUNTO_NAUTIC || path == NULL) - return DC_STATUS_INVALIDARGS; + if (HANDLE_SLOT (handle) == SLOT_STATIC || HANDLE_SLOT (handle) == SLOT_UNKNOWN) + return; - suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract; + unsigned char reply[REPLY_CONTENT] = {0}; + size_t reply_len = 0; + dc_status_t status = suunto_nautic_transfer (device, RPC_OP_RELEASE, RPC_OP_RELEASE_REPLY, + handle, NULL, 0, reply, sizeof (reply), &reply_len); + if (status != DC_STATUS_SUCCESS || array_uint16_le (reply + REPLY_STATUS) != STATUS_OK) + WARNING (device->base.context, "Failed to release handle %06x.", handle); +} - 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); +// Resolve a path that must exist. +static dc_status_t +suunto_nautic_get_handle (suunto_nautic_device_t *device, const char *path, unsigned int *handle) +{ + unsigned int result = 0; + dc_status_t status = suunto_nautic_get (device, path, handle, &result); if (status != DC_STATUS_SUCCESS) return status; - device->sequence++; - return suunto_nautic_transfer (device, packet, len, response); + if (result != STATUS_OK) { + ERROR (device->base.context, "Failed to resolve %s (status %u).", path, result); + return DC_STATUS_PROTOCOL; + } + + // Every handle is released before the next GET, so a still bound 00 slot + // was left by a link that dropped during a transfer. + if (HANDLE_SLOT (*handle) == SLOT_ALT) { + WARNING (device->base.context, "Releasing the stale slot of %s.", path); + suunto_nautic_release (device, (*handle & ~0xFFu) | SLOT_DEFAULT); + } + + return status; } -// 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). +// Decompress a Heatshrink (LZSS) stream, with the parameters above. static dc_status_t suunto_nautic_heatshrink_decompress (dc_context_t *context, const unsigned char *input, size_t input_size, dc_buffer_t *output) { @@ -637,513 +573,228 @@ suunto_nautic_heatshrink_decompress (dc_context_t *context, const unsigned char return status; } -// 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; -} - -static dc_status_t suunto_nautic_get_handle (dc_device_t *abstract, const char *path, unsigned char handle[3]); - -// Release a handle a GET resolved, so the next GET of the same resource binds -// its own id to the slot. Best-effort: a failure only leaves the slot bound. -static void -suunto_nautic_release_handle (dc_device_t *abstract, const unsigned char handle[3]) -{ - suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract; - - if (handle[0] == RPC_HANDLE_STATIC || handle[0] == RPC_HANDLE_UNKNOWN) - return; - - unsigned char packet[32]; - unsigned int len = 0; - if (suunto_nautic_build_release (packet, sizeof (packet), &len, device->sequence, handle) != DC_STATUS_SUCCESS) - return; - device->sequence++; - - dc_buffer_t *reply = dc_buffer_new (0); - if (reply == NULL) - return; - if (suunto_nautic_transfer (device, packet, len, reply) != DC_STATUS_SUCCESS) - WARNING (abstract->context, "Failed to release handle %02x %02x %02x.", handle[0], handle[1], handle[2]); - dc_buffer_free (reply); -} - -// Pull a large streamed resource (dive data): GET -> ACK(handle) -> subscribe -// on that handle -> collect the chunk stream -> unsubscribe -> release. 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. +// Download a streamed resource (the dive profile): GET, SUBSCRIBE, the +// notifications up to the end marker, UNSUBSCRIBE and RELEASE. Returns the +// still compressed stream. Returns DC_STATUS_PROTOCOL when the watch refuses +// the stream (status 423 while the previous stream's handle is still bound). static dc_status_t -suunto_nautic_device_stream_fetch (dc_device_t *abstract, const char *path, dc_buffer_t *raw) +suunto_nautic_stream (suunto_nautic_device_t *device, const char *path, dc_buffer_t *buffer) { - suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract; + dc_device_t *abstract = (dc_device_t *) device; dc_status_t status = DC_STATUS_SUCCESS; + dc_status_t rc = DC_STATUS_SUCCESS; - // 1. Resolve the path. The handle in the ACK is bound to this dive; a - // fixed handle would address whichever dive an earlier GET left bound. - unsigned char handle[3]; - status = suunto_nautic_get_handle (abstract, path, handle); + unsigned int handle = 0; + status = suunto_nautic_get_handle (device, path, &handle); if (status != DC_STATUS_SUCCESS) return status; - // 2. Subscribe to the handle; the watch answers 0x08 and streams. - unsigned char req[32]; - unsigned int req_len = 0; - status = suunto_nautic_build_handle_op (req, sizeof (req), &req_len, device->sequence, RPC_OP_SUBSCRIBE, handle); - if (status != DC_STATUS_SUCCESS) - goto release; - device->sequence++; - - status = dc_iostream_write (device->iostream, req, req_len, NULL); + unsigned char reply[REPLY_CONTENT + 2] = {0}; + size_t reply_len = 0; + status = suunto_nautic_transfer (device, RPC_OP_SUBSCRIBE, RPC_OP_SUBSCRIBE_REPLY, + handle, suunto_nautic_noparams, sizeof (suunto_nautic_noparams), reply, sizeof (reply), &reply_len); if (status != DC_STATUS_SUCCESS) { ERROR (abstract->context, "Failed to subscribe to %s.", path); - goto release; + goto error_release; + } + + unsigned int result = array_uint16_le (reply + REPLY_STATUS); + if (result != STATUS_OK) { + ERROR (abstract->context, "The watch refused the stream for %s (status %u).", path, result); + status = DC_STATUS_PROTOCOL; + goto error_release; } - // 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 subscribed it streams the - // entire response continuously, and the host buffers until a 2.0s - // inter-frame silence. MAX_CHUNKS is only a runaway guard. - status = dc_iostream_set_timeout (device->iostream, 2000); + status = dc_iostream_set_timeout (device->iostream, TIMEOUT_STREAM); if (status != DC_STATUS_SUCCESS) { - ERROR (abstract->context, "Failed to set the stream timeout."); - goto release; + ERROR (abstract->context, "Failed to set the timeout."); + goto error_unsubscribe; } - // A long dive streams for minutes, so a cancel is honoured between frames; - // the stream is still stopped below so the watch doesn't keep sending it. - unsigned int cancelled = 0; - unsigned int nframes = 0; - for (nframes = 0; nframes < MAX_CHUNKS; nframes++) { + // The watch streams the whole resource without acknowledgements, and + // repeats its last chunk until the subscription ends. + dc_buffer_clear (buffer); + while (1) { if (device_is_cancelled (abstract)) { - cancelled = 1; + status = DC_STATUS_CANCELLED; break; } - unsigned char packet[MAX_PACKET] = {0}; - size_t len = 0; - status = dc_iostream_read (device->iostream, packet, sizeof (packet), &len); + unsigned char chunk[MAX_PAYLOAD] = {0}; + size_t chunk_len = 0; + status = suunto_nautic_recv (device, RPC_OP_NOTIFICATION, 0, chunk, sizeof (chunk), &chunk_len); + if (status == DC_STATUS_TIMEOUT) { + WARNING (abstract->context, "The stream for %s ended without an end marker.", path); + status = DC_STATUS_SUCCESS; + break; + } if (status != DC_STATUS_SUCCESS) { - if (status == DC_STATUS_TIMEOUT) - break; - ERROR (abstract->context, "Failed to receive a stream chunk."); - return status; + ERROR (abstract->context, "Failed to receive the stream for %s.", path); + break; } - if (len == 0) + if (chunk_len < CHUNK_DATA) { + ERROR (abstract->context, "Unexpected chunk size (" DC_PRINTF_SIZE ").", chunk_len); + status = DC_STATUS_PROTOCOL; break; + } - if (len >= 2 && packet[0] == 0xA5 && packet[1] == RPC_OP_STREAM_END) + unsigned int offset = array_uint32_le (chunk + CHUNK_OFFSET); + unsigned int length = array_uint16_le (chunk + CHUNK_LENGTH); + if (CHUNK_DATA + length > chunk_len) { + ERROR (abstract->context, "Unexpected chunk length (%u " DC_PRINTF_SIZE ").", length, chunk_len); + status = DC_STATUS_PROTOCOL; break; - - // 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); - status = DC_STATUS_PROTOCOL; - goto restore; - } } - 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; + size_t received = dc_buffer_get_size (buffer); + if (offset < received) { + // A repeated chunk. + continue; } - } - status = cancelled ? DC_STATUS_CANCELLED : DC_STATUS_SUCCESS; - - 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 it the watch keeps streaming on the 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; - int stopped = suunto_nautic_build_handle_op (stop, sizeof (stop), &stop_len, device->sequence, RPC_OP_UNSUBSCRIBE, handle) == DC_STATUS_SUCCESS; - if (stopped) { - device->sequence++; - stopped = dc_iostream_write (device->iostream, stop, stop_len, NULL) == DC_STATUS_SUCCESS; - } - if (stopped) { - 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; - } + if (offset > received) { + ERROR (abstract->context, "Missing stream data (%u " DC_PRINTF_SIZE ").", offset, received); + status = DC_STATUS_PROTOCOL; + break; } - } else { - WARNING (abstract->context, "Failed to send the stream stop for %s.", path); - } - -restore: - if (dc_iostream_set_timeout (device->iostream, 5000) != DC_STATUS_SUCCESS) { - ERROR (abstract->context, "Failed to restore the timeout."); - return DC_STATUS_IO; - } - -release: - suunto_nautic_release_handle (abstract, handle); - return status; -} -// 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_get_handle (dc_device_t *abstract, const char *path, unsigned char handle[3]) -{ - dc_buffer_t *ack = dc_buffer_new (0); - if (ack == NULL) - return DC_STATUS_NOMEMORY; + if (length == 0) + break; - 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); - return status; + if (!dc_buffer_append (buffer, chunk + CHUNK_DATA, length)) { + ERROR (abstract->context, "Failed to allocate memory."); + status = DC_STATUS_NOMEMORY; + break; + } } - 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; - } - memcpy (handle, ack_data + RPC_HANDLE_OFFSET, 3); - dc_buffer_free (ack); - - // Every handle is released before the next GET, so a still-bound 00 slot - // was left by a link that dropped mid-transfer; free it. - if (handle[0] == RPC_HANDLE_SLOT_ALT) { - const unsigned char stale[3] = {RPC_HANDLE_SLOT, handle[1], handle[2]}; - WARNING (abstract->context, "%s resolved to the 10 slot; releasing the stale 00 slot.", path); - suunto_nautic_release_handle (abstract, stale); +error_unsubscribe: + // Without the UNSUBSCRIBE the watch keeps streaming, and the next GET of a + // stream is refused with status 423. + rc = suunto_nautic_transfer (device, RPC_OP_UNSUBSCRIBE, RPC_OP_UNSUBSCRIBE_REPLY, + handle, suunto_nautic_noparams, sizeof (suunto_nautic_noparams), reply, sizeof (reply), &reply_len); + if (rc != DC_STATUS_SUCCESS) + WARNING (abstract->context, "Failed to unsubscribe from %s.", path); + + rc = dc_iostream_set_timeout (device->iostream, TIMEOUT_DEFAULT); + if (rc != DC_STATUS_SUCCESS) { + ERROR (abstract->context, "Failed to restore the timeout."); + dc_status_set_error (&status, rc); } - return DC_STATUS_SUCCESS; +error_release: + suunto_nautic_release (device, handle); + return status; } -// 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). +// Read a paged resource (/Summary). Each page is requested by the offset of +// its data, and the last page has status 200. 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_fetch_pages (suunto_nautic_device_t *device, const char *path, dc_buffer_t *buffer) { - suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract; + dc_device_t *abstract = (dc_device_t *) device; + dc_status_t status = DC_STATUS_SUCCESS; - 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); + unsigned int handle = 0; + status = suunto_nautic_get_handle (device, path, &handle); + if (status != DC_STATUS_SUCCESS) 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); + dc_buffer_clear (buffer); + unsigned int offset = 0; + while (1) { + unsigned char params[7] = {0x01, PARAM_INT32, 0x00}; + array_uint32_le_set (params + 3, offset); + + unsigned char reply[MAX_PAYLOAD] = {0}; + size_t reply_len = 0; + status = suunto_nautic_transfer (device, RPC_OP_FETCH, RPC_OP_DATA, + handle, params, sizeof (params), reply, sizeof (reply), &reply_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) + ERROR (abstract->context, "Failed to fetch %s (offset %u).", path, offset); break; - if (suunto_nautic_frame_is_our_data (packet, len, handle)) + } + + unsigned int result = array_uint16_le (reply + REPLY_STATUS); + if (result != STATUS_OK && result != STATUS_CONTINUE) { + ERROR (abstract->context, "Failed to fetch %s (status %u).", path, result); + status = DC_STATUS_PROTOCOL; 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; + + if (reply_len < REPLY_CONTENT + PAGE_HEADER) { + ERROR (abstract->context, "Unexpected page size (" DC_PRINTF_SIZE ").", reply_len); + status = DC_STATUS_PROTOCOL; + break; } - WARNING (abstract->context, "Skipping malformed frame while fetching %s (" DC_PRINTF_SIZE " bytes).", path, len); - } - *out_len = len; - return DC_STATUS_SUCCESS; -} + unsigned int length = array_uint16_le (reply + REPLY_CONTENT + PAGE_LENGTH); + if (REPLY_CONTENT + PAGE_HEADER + length > reply_len) { + ERROR (abstract->context, "Unexpected page length (%u " DC_PRINTF_SIZE ").", length, reply_len); + status = DC_STATUS_PROTOCOL; + break; + } -// 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_fetch_pages (dc_device_t *abstract, const char *path, const unsigned char handle[3], dc_buffer_t *response) -{ - suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract; - dc_status_t status = DC_STATUS_SUCCESS; - - 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_param_fetch (fetch, sizeof (fetch), &fetch_len, - device->sequence, handle, RPC_PARAM_INT32, offset); - if (status != DC_STATUS_SUCCESS) - return status; - 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) - 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; - } - - 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 (!dc_buffer_append (response, page_data, length)) { + if (!dc_buffer_append (buffer, reply + REPLY_CONTENT + PAGE_HEADER, length)) { ERROR (abstract->context, "Failed to allocate memory."); - return DC_STATUS_NOMEMORY; + status = DC_STATUS_NOMEMORY; + break; } 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 (result == STATUS_OK) + break; if (length == 0) { - ERROR (abstract->context, "Empty page %u of %s with more pages pending.", page, path); - return DC_STATUS_PROTOCOL; + ERROR (abstract->context, "Empty page with more pages pending."); + status = DC_STATUS_PROTOCOL; + break; } } - WARNING (abstract->context, "Paginated fetch hit the page limit for %s -- data may be truncated.", path); - return DC_STATUS_SUCCESS; -} - -static dc_status_t -suunto_nautic_device_paginated_fetch (dc_device_t *abstract, const char *path, dc_buffer_t *response) -{ - unsigned char handle[3]; - dc_status_t status = suunto_nautic_get_handle (abstract, path, handle); - if (status != DC_STATUS_SUCCESS) - return status; - - status = suunto_nautic_fetch_pages (abstract, path, handle, response); - suunto_nautic_release_handle (abstract, handle); + suunto_nautic_release (device, handle); return status; } -// 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. +// Read a small resource that fits in a single reply (/Info). 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_fetch (suunto_nautic_device_t *device, const char *path, dc_buffer_t *buffer) { - suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract; - - unsigned char handle[3]; - dc_status_t status = suunto_nautic_get_handle (abstract, path, handle); - if (status != DC_STATUS_SUCCESS) - return status; - - 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++; + dc_device_t *abstract = (dc_device_t *) device; + dc_status_t status = DC_STATUS_SUCCESS; - unsigned char packet[MAX_PACKET] = {0}; - size_t len = 0; - status = suunto_nautic_fetch_frame (abstract, path, fetch, fetch_len, handle, skip_non_data, packet, &len); - suunto_nautic_release_handle (abstract, handle); + unsigned int handle = 0; + status = suunto_nautic_get_handle (device, path, &handle); if (status != DC_STATUS_SUCCESS) return status; - 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; -} - -// 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) -{ - 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); + unsigned char reply[MAX_PAYLOAD] = {0}; + size_t reply_len = 0; + status = suunto_nautic_transfer (device, RPC_OP_FETCH, RPC_OP_DATA, + handle, suunto_nautic_noparams, sizeof (suunto_nautic_noparams), reply, sizeof (reply), &reply_len); if (status != DC_STATUS_SUCCESS) { - dc_buffer_free (frame); - return status; + ERROR (abstract->context, "Failed to fetch %s.", path); + goto error_release; } - const unsigned char *packet = dc_buffer_get_data (frame); - size_t len = dc_buffer_get_size (frame); - - 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 status; + unsigned int result = array_uint16_le (reply + REPLY_STATUS); + if (result != STATUS_OK) { + ERROR (abstract->context, "Failed to fetch %s (status %u).", path, result); + status = DC_STATUS_PROTOCOL; + goto error_release; } - dc_buffer_clear (response); - int ok = dc_buffer_append (response, packet + 4, len - 4); - dc_buffer_free (frame); - if (!ok) { + dc_buffer_clear (buffer); + if (!dc_buffer_append (buffer, reply + REPLY_CONTENT, reply_len - REPLY_CONTENT)) { ERROR (abstract->context, "Failed to allocate memory."); - return DC_STATUS_NOMEMORY; + status = DC_STATUS_NOMEMORY; } - return DC_STATUS_SUCCESS; -} - -dc_status_t -suunto_nautic_device_fetch (dc_device_t *abstract, const char *path, dc_buffer_t *response) -{ - if (abstract == NULL || abstract->vtable->type != DC_FAMILY_SUUNTO_NAUTIC || path == NULL || response == NULL) - return DC_STATUS_INVALIDARGS; - - return suunto_nautic_device_short_fetch (abstract, path, response); -} - -dc_status_t -suunto_nautic_device_fetch_raw (dc_device_t *abstract, const char *path, dc_buffer_t *response) -{ - if (abstract == NULL || abstract->vtable->type != DC_FAMILY_SUUNTO_NAUTIC || path == NULL || response == NULL) - return DC_STATUS_INVALIDARGS; - - // 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); -} - -dc_status_t -suunto_nautic_device_fetch_paged (dc_device_t *abstract, const char *path, dc_buffer_t *response) -{ - if (abstract == NULL || abstract->vtable->type != DC_FAMILY_SUUNTO_NAUTIC || path == NULL || response == NULL) - return DC_STATUS_INVALIDARGS; - - return suunto_nautic_device_paginated_fetch (abstract, path, response); -} - -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); +error_release: + suunto_nautic_release (device, handle); + return status; } -// 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; - static void suunto_nautic_entries_free (suunto_nautic_entries_t *entries) { @@ -1176,114 +827,95 @@ suunto_nautic_entries_add (suunto_nautic_entries_t *entries, unsigned int id, un entries->ids[entries->count] = id; entries->sizes[entries->count] = size; entries->count++; - return 1; -} - -// 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) { - 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) { - 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]; - int b = (int) a - 1; - while (b >= 0 && ids[b] < key) { ids[b + 1] = ids[b]; b--; } - ids[b + 1] = key; - } - return count; + return 1; } -// 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. +// List the dives, newest first. The list is served oldest first, in pages: a +// page with status 100 is followed by a fetch for the dives after the last id +// received, until a page with status 200. static dc_status_t -suunto_nautic_device_list_entries (dc_device_t *abstract, suunto_nautic_entries_t *entries) +suunto_nautic_list (suunto_nautic_device_t *device, suunto_nautic_entries_t *entries) { - suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract; + dc_device_t *abstract = (dc_device_t *) device; + dc_status_t status = DC_STATUS_SUCCESS; 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); + unsigned int handle = 0; + status = suunto_nautic_get_handle (device, 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; + unsigned int last = 0; + for (unsigned int page = 0; ; page++) { + unsigned char params[7] = {0x01, PARAM_UINT32, 0x00}; + array_uint32_le_set (params + 3, last); + + unsigned char reply[MAX_PAYLOAD] = {0}; + size_t reply_len = 0; if (page == 0) - status = suunto_nautic_build_short_fetch (fetch, sizeof (fetch), &fetch_len, device->sequence, handle); + status = suunto_nautic_transfer (device, RPC_OP_FETCH, RPC_OP_DATA, + handle, suunto_nautic_noparams, sizeof (suunto_nautic_noparams), reply, sizeof (reply), &reply_len); 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++; + status = suunto_nautic_transfer (device, RPC_OP_FETCH, RPC_OP_DATA, + handle, params, sizeof (params), reply, sizeof (reply), &reply_len); + if (status != DC_STATUS_SUCCESS) { + ERROR (abstract->context, "Failed to fetch %s.", path); + break; + } - 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 result = array_uint16_le (reply + REPLY_STATUS); + if (result != STATUS_OK && result != STATUS_CONTINUE) { + ERROR (abstract->context, "Failed to fetch %s (status %u).", path, result); + status = DC_STATUS_PROTOCOL; + break; + } - unsigned int frame_status = 0; - status = suunto_nautic_data_status (abstract, path, packet, len, &frame_status); - if (status != DC_STATUS_SUCCESS) - goto error; + const unsigned char *data = reply + REPLY_CONTENT; + size_t size = reply_len - REPLY_CONTENT; + if (size < ENTRIES_COUNT + 2) { + ERROR (abstract->context, "Unexpected entries size (" DC_PRINTF_SIZE ").", size); + status = DC_STATUS_PROTOCOL; + break; + } + + unsigned int count = array_uint16_le (data + ENTRIES_COUNT); + if (count && ENTRIES_HEADER + count * ENTRY_SIZE > size) { + ERROR (abstract->context, "Unexpected number of entries (%u " DC_PRINTF_SIZE ").", count, size); + status = DC_STATUS_PROTOCOL; + break; + } - 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); + unsigned int previous = last; for (unsigned int i = 0; i < count; i++) { - if (!suunto_nautic_entries_add (entries, ids[i], sizes[i])) { + const unsigned char *record = data + ENTRIES_HEADER + i * ENTRY_SIZE; + last = array_uint32_le (record + ENTRY_START); + if (!suunto_nautic_entries_add (entries, last, array_uint32_le (record + ENTRY_LENGTH))) { ERROR (abstract->context, "Failed to allocate memory."); status = DC_STATUS_NOMEMORY; - goto error; + break; } } + if (status != DC_STATUS_SUCCESS) + break; - if (frame_status == RPC_STATUS_OK) + if (result == 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); + if (count == 0 || last == previous) { + ERROR (abstract->context, "No new entries with more pages pending."); + status = DC_STATUS_PROTOCOL; 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); + suunto_nautic_release (device, handle); + + if (status != DC_STATUS_SUCCESS) { + suunto_nautic_entries_free (entries); + return status; } // Sort descending (newest first), keeping each size with its id. @@ -1299,527 +931,642 @@ suunto_nautic_device_list_entries (dc_device_t *abstract, suunto_nautic_entries_ 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 (abstract == NULL || abstract->vtable->type != DC_FAMILY_SUUNTO_NAUTIC || out == NULL) - return DC_STATUS_INVALIDARGS; - - suunto_nautic_entries_t entries; - dc_status_t status = suunto_nautic_device_list_entries (abstract, &entries); - if (status != DC_STATUS_SUCCESS) - return status; - - dc_buffer_clear (out); - for (unsigned int i = 0; i < entries.count; i++) { - unsigned char le[4]; - array_uint32_le_set (le, entries.ids[i]); - if (!dc_buffer_append (out, le, sizeof (le))) { - suunto_nautic_entries_free (&entries); - return DC_STATUS_NOMEMORY; - } - } - - 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`. -// `*unreadable` is set on a failure where the transfer completed and matched -// the listing but holds no decodable dive (an empty/aborted logbook entry): -// downloading it again cannot help. Any other failure may succeed later. +// Download one dive: the decompressed /Data stream with the /Summary +// appended. The listed size is the compressed /Data plus the /Summary data +// bytes, exactly. A mismatch is retried once and then reported through +// `incomplete`, with the dive left in `buffer`. `unreadable` is set when the +// transfer matched the listing but holds no dive (an empty or aborted +// logbook entry), so downloading it again cannot help. 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, int *unreadable) +suunto_nautic_download (suunto_nautic_device_t *device, unsigned int id, unsigned int listed, + dc_buffer_t *buffer, int *incomplete, int *unreadable) { - suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract; + dc_device_t *abstract = (dc_device_t *) device; dc_status_t status = DC_STATUS_SUCCESS; *incomplete = 0; *unreadable = 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; + char data_path[64], summary_path[64]; + snprintf (data_path, sizeof (data_path), "/Logbook/byId/%u/Data", id); + snprintf (summary_path, sizeof (summary_path), "/Logbook/byId/%u/Summary", id); dc_buffer_t *compressed = dc_buffer_new (0); dc_buffer_t *summary = dc_buffer_new (0); if (compressed == NULL || summary == NULL) { + ERROR (abstract->context, "Failed to allocate memory."); status = DC_STATUS_NOMEMORY; - goto done; + goto error_free; } 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); + for (unsigned int i = 0; i < STREAM_ATTEMPTS; i++) { + if (i > 0) { + WARNING (abstract->context, "Retrying the stream of dive %u.", id); + dc_iostream_sleep (device->iostream, 1500 * i); } - dc_buffer_clear (compressed); - status = suunto_nautic_device_stream_fetch (abstract, path, compressed); + status = suunto_nautic_stream (device, data_path, compressed); if (status != DC_STATUS_PROTOCOL) break; } if (status != DC_STATUS_SUCCESS) - goto done; + goto error_free; - 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. + // The profile is still a valid dive without the /Summary. size_t summary_size = 0; - if (suunto_nautic_device_download_summary (abstract, logbook_id, summary) == DC_STATUS_SUCCESS) + if (suunto_nautic_fetch_pages (device, summary_path, summary) == DC_STATUS_SUCCESS) { summary_size = dc_buffer_get_size (summary); - else { + } else { + WARNING (abstract->context, "Failed to fetch the summary of dive %u.", id); 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) + if (listed == 0) break; size_t data_size = dc_buffer_get_size (compressed); if (summary_size) - *incomplete = data_size + summary_size != listed_size; + *incomplete = data_size + summary_size != listed; else - *incomplete = data_size > listed_size || listed_size - data_size > SUMMARY_MAX_SIZE; + *incomplete = data_size > listed || listed - 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); + WARNING (abstract->context, "Incomplete download of dive %u (" DC_PRINTF_SIZE " + " DC_PRINTF_SIZE " bytes, listed %u).", + id, data_size, summary_size, listed); } status = suunto_nautic_heatshrink_decompress (abstract->context, - dc_buffer_get_data (compressed), dc_buffer_get_size (compressed), raw); + dc_buffer_get_data (compressed), dc_buffer_get_size (compressed), buffer); if (status != DC_STATUS_SUCCESS) { - ERROR (abstract->context, "Failed to decompress the logbook entry."); + ERROR (abstract->context, "Failed to decompress dive %u.", id); *unreadable = status == DC_STATUS_DATAFORMAT && !*incomplete; - goto done; + goto error_free; } - 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."); + if (dc_buffer_get_size (buffer) < sizeof (SBEM_MAGIC) || + memcmp (dc_buffer_get_data (buffer), SBEM_MAGIC, sizeof (SBEM_MAGIC)) != 0) { + ERROR (abstract->context, "Unexpected magic in dive %u.", id); status = DC_STATUS_DATAFORMAT; *unreadable = !*incomplete; - goto done; + goto error_free; } - 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."); + // The /Summary goes after the profile, so the parser can read the fields + // that are not in the profile (gradient factors, gas mixes). + if (!dc_buffer_append (buffer, dc_buffer_get_data (summary), dc_buffer_get_size (summary))) { + ERROR (abstract->context, "Failed to allocate memory."); + status = DC_STATUS_NOMEMORY; + } -done: +error_free: if (status != DC_STATUS_SUCCESS) - dc_buffer_clear (raw); + dc_buffer_clear (buffer); 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) +// True for a token that is exactly "..", each part +// < 256; on success fills a/b/c. +static int +suunto_nautic_parse_version (const char *tok, size_t len, unsigned int *a, unsigned int *b, unsigned int *c) { - 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]; + unsigned int part[3] = {0}, idx = 0, digits = 0; + for (size_t i = 0; i < len; i++) { + char ch = tok[i]; + if (ch >= '0' && ch <= '9') { + part[idx] = part[idx] * 10 + (unsigned int) (ch - '0'); + if (part[idx] > 255 || ++digits > 3) + return 0; + } else if (ch == '.') { + if (digits == 0 || ++idx > 2) + return 0; + digits = 0; + } else { + return 0; } - suunto_nautic_entries_free (&entries); } + if (idx != 2 || digits == 0) + return 0; + *a = part[0]; *b = part[1]; *c = part[2]; + return 1; +} - int incomplete = 0, unreadable = 0; - dc_status_t status = suunto_nautic_device_download_dive (abstract, logbook_id, listed_size, raw, &incomplete, &unreadable); - if (status == DC_STATUS_SUCCESS && incomplete) - return DC_STATUS_DATAFORMAT; - return status; +// True for a token that is exactly 12 chars, all [0-9A-F] -- the watch serial +// form, e.g. "2604C3003306". +static int +suunto_nautic_is_serial (const char *tok, size_t len) +{ + if (len != 12) + return 0; + for (size_t i = 0; i < len; i++) { + char ch = tok[i]; + if (!((ch >= '0' && ch <= '9') || (ch >= 'A' && ch <= 'F'))) + return 0; + } + return 1; } -// True for a token that is exactly "..", each part -// < 256; on success fills a/b/c. -// Send a handle-addressed request (subscribe/unsubscribe) and read until the -// reply with `reply_op` echoing its message id; other frames on the link are -// skipped. On success `reply` holds the whole reply frame. +// /Info is a run of NUL-terminated strings mixed with binary fields: +// +// Suunto\0 Nautic\0 Vaasa\0 T\0 2604C3003306\0 ... 2.55.46\0 ... +// BLE Mac Address\0 \0 WiFi Mac Address\0 \0 +// +// The serial is the first 12-character uppercase hex string, and the firmware +// the first "N.N.N" string, which follows the serial. The MAC addresses after +// it are also 12 hex characters. The serial is reported as its low 32 bits. static dc_status_t -suunto_nautic_handle_request (dc_device_t *abstract, unsigned char opcode, unsigned char reply_op, - const unsigned char handle[3], dc_buffer_t *reply) +suunto_nautic_devinfo (suunto_nautic_device_t *device) { - suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract; - - if (device_is_cancelled (abstract)) - return DC_STATUS_CANCELLED; + dc_device_t *abstract = (dc_device_t *) device; + dc_status_t status = DC_STATUS_SUCCESS; - unsigned char req[32]; - unsigned int req_len = 0; - unsigned int msgid = device->sequence & 0xFFFF; - dc_status_t status = suunto_nautic_build_handle_op (req, sizeof (req), &req_len, msgid, opcode, handle); - if (status != DC_STATUS_SUCCESS) - return status; - device->sequence++; + dc_buffer_t *info = dc_buffer_new (0); + if (info == NULL) { + ERROR (abstract->context, "Failed to allocate memory."); + return DC_STATUS_NOMEMORY; + } - status = dc_iostream_write (device->iostream, req, req_len, NULL); + status = suunto_nautic_fetch (device, "/Info", info); if (status != DC_STATUS_SUCCESS) { - ERROR (abstract->context, "Failed to send the 0x%02x request.", opcode); + ERROR (abstract->context, "Failed to read the device info."); + dc_buffer_free (info); return status; } - unsigned int skips = 0; - for (;;) { - 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 reply to the 0x%02x request.", opcode); - return status; - } - if (len >= RPC_STATUS_OFFSET + 2 && packet[0] == 0xA5 && packet[1] == reply_op && - array_uint16_le (packet + RPC_MSGID_OFFSET) == msgid) { - dc_buffer_clear (reply); - if (!dc_buffer_append (reply, packet, len)) - return DC_STATUS_NOMEMORY; - return DC_STATUS_SUCCESS; - } - if (++skips >= MAX_FOREIGN_SKIPS) { - ERROR (abstract->context, "No reply to the 0x%02x request among the frames on the link.", opcode); - return DC_STATUS_TIMEOUT; + const unsigned char *data = dc_buffer_get_data (info); + size_t size = dc_buffer_get_size (info); + + dc_event_devinfo_t devinfo; + memset (&devinfo, 0, sizeof (devinfo)); + + size_t start = 0; + for (size_t i = 0; i < size; i++) { + if (data[i] != 0) + continue; + const char *token = (const char *) (data + start); + size_t length = i - start; + unsigned int a, b, c; + if (!devinfo.firmware && suunto_nautic_parse_version (token, length, &a, &b, &c)) { + devinfo.firmware = (a << 16) | (b << 8) | c; + } else if (!devinfo.firmware && !devinfo.serial && suunto_nautic_is_serial (token, length)) { + char serial[13]; + memcpy (serial, token, 12); + serial[12] = 0; + devinfo.serial = (unsigned int) strtoul (serial, NULL, 16); } + start = i + 1; } + + dc_buffer_free (info); + + if (devinfo.firmware == 0 || devinfo.serial == 0) { + ERROR (abstract->context, "Failed to find the firmware and serial in the device info."); + return DC_STATUS_DATAFORMAT; + } + + device_event_emit (abstract, DC_EVENT_DEVINFO, &devinfo); + + return status; } -dc_status_t -suunto_nautic_device_subscribe (dc_device_t *abstract, const char *path, unsigned char handle[3], dc_buffer_t *value) +static dc_status_t +suunto_nautic_device_foreach (dc_device_t *abstract, dc_dive_callback_t callback, void *userdata) { - if (abstract == NULL || abstract->vtable->type != DC_FAMILY_SUUNTO_NAUTIC || path == NULL || handle == NULL) - return DC_STATUS_INVALIDARGS; + 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); - dc_status_t status = suunto_nautic_get_handle (abstract, path, handle); + status = suunto_nautic_devinfo (device); if (status != DC_STATUS_SUCCESS) return status; - // An unknown path is acknowledged with the FF FF FF handle (and a 404). - if (handle[0] == 0xFF && handle[1] == 0xFF && handle[2] == 0xFF) { - WARNING (abstract->context, "The watch has no %s resource.", path); - return DC_STATUS_UNSUPPORTED; + progress.current = 1; + device_event_emit (abstract, DC_EVENT_PROGRESS, &progress); + + suunto_nautic_entries_t entries; + status = suunto_nautic_list (device, &entries); + if (status != DC_STATUS_SUCCESS) { + ERROR (abstract->context, "Failed to list the dives."); + return status; } - dc_buffer_t *reply = dc_buffer_new (0); - if (reply == NULL) + progress.maximum = (entries.count + 1) * 2; + device_event_emit (abstract, DC_EVENT_PROGRESS, &progress); + + dc_buffer_t *buffer = dc_buffer_new (0); + if (buffer == NULL) { + ERROR (abstract->context, "Failed to allocate memory."); + suunto_nautic_entries_free (&entries); return DC_STATUS_NOMEMORY; + } - status = suunto_nautic_handle_request (abstract, RPC_OP_SUBSCRIBE, RPC_OP_SUBSCRIBE_REPLY, handle, reply); - if (status == DC_STATUS_SUCCESS) { - const unsigned char *packet = dc_buffer_get_data (reply); - size_t len = dc_buffer_get_size (reply); - unsigned int frame_status = array_uint16_le (packet + RPC_STATUS_OFFSET); - if (frame_status != RPC_STATUS_OK) { - ERROR (abstract->context, "Watch refused the subscription to %s (status %u).", path, frame_status); - status = DC_STATUS_PROTOCOL; - } else if (value) { - // The reply carries the current value: [type:u16][value], then the CRC. - size_t start = RPC_STATUS_OFFSET + 2; - dc_buffer_clear (value); - if (len > start + RPC_CRC_SIZE && !dc_buffer_append (value, packet + start, len - start - RPC_CRC_SIZE)) - status = DC_STATUS_NOMEMORY; + for (unsigned int i = 0; i < entries.count; i++) { + unsigned char fingerprint[4]; + array_uint32_le_set (fingerprint, entries.ids[i]); + + // The dives are newest first, so everything from here on was + // downloaded before. + if (memcmp (fingerprint, device->fingerprint, sizeof (fingerprint)) == 0) + break; + + if (device_is_cancelled (abstract)) { + status = DC_STATUS_CANCELLED; + break; } + + int incomplete = 0, unreadable = 0; + status = suunto_nautic_download (device, entries.ids[i], entries.sizes[i], buffer, &incomplete, &unreadable); + if (status != DC_STATUS_SUCCESS && unreadable) { + // An empty or aborted entry: listed, but without a dive. + WARNING (abstract->context, "Skipping dive %u without any data.", entries.ids[i]); + status = DC_STATUS_SUCCESS; + continue; + } + if (status != DC_STATUS_SUCCESS) { + // Stop here: the fingerprint of a newer dive would mark this one + // as downloaded. + ERROR (abstract->context, "Failed to download dive %u.", entries.ids[i]); + break; + } + + // Delivered anyway: skipping it would move the fingerprint past it. + if (incomplete) + WARNING (abstract->context, "Dive %u doesn't match its listed size and may be truncated.", entries.ids[i]); + + progress.current += 2; + device_event_emit (abstract, DC_EVENT_PROGRESS, &progress); + + if (callback && !callback (dc_buffer_get_data (buffer), dc_buffer_get_size (buffer), fingerprint, sizeof (fingerprint), userdata)) + break; } - dc_buffer_free (reply); + dc_buffer_free (buffer); + suunto_nautic_entries_free (&entries); + return status; } -dc_status_t -suunto_nautic_device_unsubscribe (dc_device_t *abstract, const unsigned char handle[3]) +/* + * Extension API (include/libdivecomputer/suunto_nautic.h): direct access for + * tools that list, download or explore the watch outside dc_device_foreach(). + */ + +static int +suunto_nautic_is_device (dc_device_t *abstract) +{ + return abstract != NULL && abstract->vtable->type == DC_FAMILY_SUUNTO_NAUTIC; +} + +// Rebuild a received packet around its payload. The payload passed the +// checksum, so the packet is byte for byte the one the watch sent. +static dc_status_t +suunto_nautic_packet (dc_buffer_t *buffer, unsigned char opcode, unsigned int seqnum, const unsigned char data[], size_t size) { - if (abstract == NULL || abstract->vtable->type != DC_FAMILY_SUUNTO_NAUTIC || handle == NULL) + unsigned char packet[RPC_OVERHEAD + MAX_PAYLOAD] = {0}; + + if (size > MAX_PAYLOAD) return DC_STATUS_INVALIDARGS; - dc_buffer_t *reply = dc_buffer_new (0); - if (reply == NULL) + packet[0] = RPC_START; + packet[1] = opcode; + array_uint16_le_set (packet + 2, size); + array_uint16_le_set (packet + 4, seqnum); + memcpy (packet + 6, data, size); + array_uint32_le_set (packet + 6 + size, checksum_crc32r (packet, 6 + size)); + + dc_buffer_clear (buffer); + if (!dc_buffer_append (buffer, packet, RPC_OVERHEAD + size)) return DC_STATUS_NOMEMORY; - dc_status_t status = suunto_nautic_handle_request (abstract, RPC_OP_UNSUBSCRIBE, RPC_OP_UNSUBSCRIBE_REPLY, handle, reply); - dc_buffer_free (reply); - if (status == DC_STATUS_SUCCESS) - suunto_nautic_release_handle (abstract, handle); - return status; + return DC_STATUS_SUCCESS; +} + +int +suunto_nautic_device_handshake_acknowledged (dc_device_t *abstract) +{ + if (!suunto_nautic_is_device (abstract)) + return 0; + + return ((suunto_nautic_device_t *) abstract)->acknowledged; } dc_status_t -suunto_nautic_device_wait_notification (dc_device_t *abstract, unsigned int timeout, unsigned char handle[3], dc_buffer_t *value) +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 || handle == NULL || value == NULL) + if (!suunto_nautic_is_device (abstract) || path == NULL || response == NULL) return DC_STATUS_INVALIDARGS; suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract; + dc_status_t status = DC_STATUS_SUCCESS; - if (device_is_cancelled (abstract)) - return DC_STATUS_CANCELLED; + size_t pathlen = strlen (path); + if (pathlen == 0 || pathlen > MAX_PATH) + return DC_STATUS_INVALIDARGS; - dc_status_t status = dc_iostream_set_timeout (device->iostream, (int) timeout); + unsigned char request[4 + MAX_PATH] = {0x01, 0x80, 0x00}; + request[3] = pathlen; + memcpy (request + 4, path, pathlen); + + unsigned int seqnum = suunto_nautic_seqnum (device); + status = suunto_nautic_send (device, RPC_OP_GET, seqnum, request, 4 + pathlen); if (status != DC_STATUS_SUCCESS) return status; - // A value notification is [A5 01 sublen:2][msgid:2][handle:3][01 80 00][type:u16][value...][crc:4]. - const size_t header = RPC_HANDLE_OFFSET + 3 + 3; - for (unsigned int skips = 0; skips < MAX_FOREIGN_SKIPS; skips++) { - 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) - break; - HEXDUMP (abstract->context, DC_LOGLEVEL_DEBUG, "NOTIFY", packet, len); - if (len < header + RPC_CRC_SIZE || packet[0] != 0xA5 || packet[1] != RPC_OP_NOTIFICATION) - continue; - memcpy (handle, packet + RPC_HANDLE_OFFSET, 3); - dc_buffer_clear (value); - if (!dc_buffer_append (value, packet + header, len - header - RPC_CRC_SIZE)) + unsigned char reply[MAX_PAYLOAD] = {0}; + size_t reply_len = 0; + status = suunto_nautic_recv (device, RPC_OP_GET_REPLY, seqnum, reply, sizeof (reply), &reply_len); + if (status != DC_STATUS_SUCCESS) + return status; + + // The whole reply packet. + return suunto_nautic_packet (response, RPC_OP_GET_REPLY, seqnum, reply, reply_len); +} + +// GET, a FETCH without parameters, RELEASE. Returns the whole reply packet. +static dc_status_t +suunto_nautic_fetch_packet (suunto_nautic_device_t *device, const char *path, dc_buffer_t *packet, unsigned int *result) +{ + unsigned int handle = 0; + dc_status_t status = suunto_nautic_get_handle (device, path, &handle); + if (status != DC_STATUS_SUCCESS) + return status; + + unsigned char reply[MAX_PAYLOAD] = {0}; + size_t reply_len = 0; + status = suunto_nautic_transfer (device, RPC_OP_FETCH, RPC_OP_DATA, + handle, suunto_nautic_noparams, sizeof (suunto_nautic_noparams), reply, sizeof (reply), &reply_len); + unsigned int seqnum = device->seqnum; + suunto_nautic_release (device, handle); + if (status != DC_STATUS_SUCCESS) + return status; + + *result = array_uint16_le (reply + REPLY_STATUS); + return suunto_nautic_packet (packet, RPC_OP_DATA, seqnum, reply, reply_len); +} + +dc_status_t +suunto_nautic_device_fetch (dc_device_t *abstract, const char *path, dc_buffer_t *response) +{ + if (!suunto_nautic_is_device (abstract) || path == NULL || response == NULL) + return DC_STATUS_INVALIDARGS; + + dc_buffer_t *packet = dc_buffer_new (0); + if (packet == NULL) + return DC_STATUS_NOMEMORY; + + unsigned int result = 0; + dc_status_t status = suunto_nautic_fetch_packet ((suunto_nautic_device_t *) abstract, path, packet, &result); + if (status == DC_STATUS_SUCCESS && result != STATUS_OK && result != STATUS_CONTINUE) { + ERROR (abstract->context, "Failed to fetch %s (status %u).", path, result); + status = DC_STATUS_PROTOCOL; + } + + // Everything after the start byte, opcode and length. A status 100 reply + // is the first page only. + if (status == DC_STATUS_SUCCESS) { + dc_buffer_clear (response); + if (!dc_buffer_append (response, dc_buffer_get_data (packet) + 4, dc_buffer_get_size (packet) - 4)) status = DC_STATUS_NOMEMORY; - break; } - dc_status_t restore = dc_iostream_set_timeout (device->iostream, 5000); - return status != DC_STATUS_SUCCESS ? status : restore; + dc_buffer_free (packet); + return status; } -static int -suunto_nautic_parse_version (const char *tok, size_t len, unsigned int *a, unsigned int *b, unsigned int *c) +dc_status_t +suunto_nautic_device_fetch_raw (dc_device_t *abstract, const char *path, dc_buffer_t *response) { - unsigned int part[3] = {0}, idx = 0, digits = 0; - for (size_t i = 0; i < len; i++) { - char ch = tok[i]; - if (ch >= '0' && ch <= '9') { - part[idx] = part[idx] * 10 + (unsigned int) (ch - '0'); - if (part[idx] > 255 || ++digits > 3) - return 0; - } else if (ch == '.') { - if (digits == 0 || ++idx > 2) - return 0; - digits = 0; - } else { - return 0; + if (!suunto_nautic_is_device (abstract) || path == NULL || response == NULL) + return DC_STATUS_INVALIDARGS; + + unsigned int result = 0; + return suunto_nautic_fetch_packet ((suunto_nautic_device_t *) abstract, path, response, &result); +} + +dc_status_t +suunto_nautic_device_list (dc_device_t *abstract, dc_buffer_t *ids) +{ + if (!suunto_nautic_is_device (abstract) || ids == NULL) + return DC_STATUS_INVALIDARGS; + + suunto_nautic_entries_t entries; + dc_status_t status = suunto_nautic_list ((suunto_nautic_device_t *) abstract, &entries); + if (status != DC_STATUS_SUCCESS) + return status; + + dc_buffer_clear (ids); + for (unsigned int i = 0; i < entries.count; i++) { + unsigned char id[4]; + array_uint32_le_set (id, entries.ids[i]); + if (!dc_buffer_append (ids, id, sizeof (id))) { + status = DC_STATUS_NOMEMORY; + break; } } - if (idx != 2 || digits == 0) - return 0; - *a = part[0]; *b = part[1]; *c = part[2]; - return 1; + + suunto_nautic_entries_free (&entries); + return status; } -// True for a token that is exactly 12 chars, all [0-9A-F] -- the watch serial -// form, e.g. "2604C3003306". -static int -suunto_nautic_is_serial (const char *tok, size_t len) +// Scans a raw /Logbook/Entries response for 4-aligned (start, end) timestamp +// pairs. The driver parses the records instead; kept for existing callers. +unsigned int +suunto_nautic_extract_entry_ids (const unsigned char data[], size_t size, unsigned int *ids, unsigned int max_ids) { - if (len != 12) - return 0; - for (size_t i = 0; i < len; i++) { - char ch = tok[i]; - if (!((ch >= '0' && ch <= '9') || (ch >= 'A' && ch <= 'F'))) - return 0; + unsigned int count = 0; + for (size_t i = 0; i + 8 <= size && count < max_ids; i += 4) { + unsigned int start = array_uint32_le (data + i); + if (start < 1500000000u || start > 2100000000u) + continue; + unsigned int end = array_uint32_le (data + i + 4); + if (end >= 1500000000u && end <= 2100000000u && end > start && end - start <= 86400u) { + ids[count++] = start; + i += 4; + } } - return 1; + + 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; } -/* - * Best-effort device info. A short fetch of /Info returns a device-identity - * record: a run of NUL-separated strings mixed in with binary framing bytes, - * - * Suunto\0 Nautic\0 Vaasa\0 T\0 2604C3003306\0 01385D42<...>\0 - * 2.55.46\0 \0 ... SIM\0 \0 BID\0 \0 - * BLE Mac Address\0 \0 WiFi Mac Address\0 \0 - * - * (confirmed on a live Nautic, firmware 2.55.46, via fetch_device_info.py). - * The watch serial is the first 12-char uppercase-hex token and the firmware - * the first "N.N.N" token; the serial always precedes the firmware, and the - * BLE/WiFi MACs (also 12 hex) come after it -- so stop looking for a serial - * once the firmware token is seen. Layered on top of the download; every - * failure path here is non-fatal. - * - * dc_event_devinfo_t carries unsigned ints only: the firmware is packed - * (a << 16) | (b << 8) | c and the hex serial truncated to its low 32 bits. - * A consumer wanting the faithful strings should read the BLE advertised - * name (serial) or GET /Info directly (firmware). - */ -static void -suunto_nautic_emit_devinfo (dc_device_t *abstract) +dc_status_t +suunto_nautic_device_fetch_paged (dc_device_t *abstract, const char *path, dc_buffer_t *response) { - dc_buffer_t *info = dc_buffer_new (0); - if (info == NULL) - return; + if (!suunto_nautic_is_device (abstract) || path == NULL || response == NULL) + return DC_STATUS_INVALIDARGS; - if (suunto_nautic_device_short_fetch (abstract, "/Info", info) != DC_STATUS_SUCCESS) { - dc_buffer_free (info); - return; // not fatal -- just no device info this time - } + return suunto_nautic_fetch_pages ((suunto_nautic_device_t *) abstract, path, response); +} - const unsigned char *d = dc_buffer_get_data (info); - size_t n = dc_buffer_get_size (info); +dc_status_t +suunto_nautic_device_download_summary (dc_device_t *abstract, const char *logbook_id, dc_buffer_t *summary) +{ + if (!suunto_nautic_is_device (abstract) || logbook_id == NULL || summary == NULL) + return DC_STATUS_INVALIDARGS; - dc_event_devinfo_t devinfo; - memset (&devinfo, 0, sizeof (devinfo)); + 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; - // Walk NUL-delimited tokens; the record's strings are NUL-terminated, and - // the binary framing bytes between them fail both tests. - size_t start = 0; - for (size_t i = 0; i < n; i++) { - if (d[i] != 0) - continue; - const char *tok = (const char *) (d + start); - size_t toklen = i - start; - unsigned int a, b, c; - if (!devinfo.firmware && suunto_nautic_parse_version (tok, toklen, &a, &b, &c)) - devinfo.firmware = (a << 16) | (b << 8) | c; - else if (!devinfo.firmware && !devinfo.serial && suunto_nautic_is_serial (tok, toklen)) { - char buf[13]; - memcpy (buf, tok, 12); - buf[12] = 0; - devinfo.serial = (unsigned int) strtoul (buf, NULL, 16); + return suunto_nautic_fetch_pages ((suunto_nautic_device_t *) abstract, path, summary); +} + +dc_status_t +suunto_nautic_device_download (dc_device_t *abstract, const char *logbook_id, dc_buffer_t *raw) +{ + if (!suunto_nautic_is_device (abstract) || logbook_id == NULL || raw == NULL) + return DC_STATUS_INVALIDARGS; + + suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract; + + char *end = NULL; + unsigned long id = strtoul (logbook_id, &end, 10); + if (end == logbook_id || *end != 0 || id == 0 || id > 0xFFFFFFFFul) + return DC_STATUS_INVALIDARGS; + + // The listed size to check the download against; a dive that isn't + // listed is downloaded without the check. + unsigned int listed = 0; + suunto_nautic_entries_t entries; + if (suunto_nautic_list (device, &entries) == DC_STATUS_SUCCESS) { + for (unsigned int i = 0; i < entries.count; i++) { + if (entries.ids[i] == id) + listed = entries.sizes[i]; } - start = i + 1; + suunto_nautic_entries_free (&entries); } - dc_buffer_free (info); + int incomplete = 0, unreadable = 0; + dc_status_t status = suunto_nautic_download (device, (unsigned int) id, listed, raw, &incomplete, &unreadable); + if (status == DC_STATUS_SUCCESS && incomplete) + return DC_STATUS_DATAFORMAT; - if (devinfo.firmware || devinfo.serial) { - INFO (abstract->context, "Device info: firmware=%u.%u.%u serial=0x%08x", - (devinfo.firmware >> 16) & 0xFF, (devinfo.firmware >> 8) & 0xFF, - devinfo.firmware & 0xFF, devinfo.serial); - device_event_emit (abstract, DC_EVENT_DEVINFO, &devinfo); - } + return status; } -static dc_status_t -suunto_nautic_device_foreach (dc_device_t *abstract, dc_dive_callback_t callback, void *userdata) +dc_status_t +suunto_nautic_device_subscribe (dc_device_t *abstract, const char *path, unsigned char handle[3], dc_buffer_t *value) { - 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); + if (!suunto_nautic_is_device (abstract) || path == NULL || handle == NULL) + return DC_STATUS_INVALIDARGS; - // 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; + suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract; - 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)."); + unsigned int h = 0, result = 0; + dc_status_t status = suunto_nautic_get (device, path, &h, &result); + if (status != DC_STATUS_SUCCESS) return status; + + if (HANDLE_SLOT (h) == SLOT_UNKNOWN) { + WARNING (abstract->context, "The watch has no %s resource.", path); + return DC_STATUS_UNSUPPORTED; } + if (result != STATUS_OK) { + ERROR (abstract->context, "Failed to resolve %s (status %u).", path, result); + return DC_STATUS_PROTOCOL; + } + array_uint24_le_set (handle, h); - 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); + // A still bound 00 slot was left by a link that dropped. + if (HANDLE_SLOT (h) == SLOT_ALT) { + WARNING (abstract->context, "Releasing the stale slot of %s.", path); + suunto_nautic_release (device, (h & ~0xFFu) | SLOT_DEFAULT); + } - // Best-effort firmware / serial via DC_EVENT_DEVINFO; never fatal. - suunto_nautic_emit_devinfo (abstract); + // The reply carries the current value: TYPE(2) VALUE. + unsigned char reply[MAX_PAYLOAD] = {0}; + size_t reply_len = 0; + status = suunto_nautic_transfer (device, RPC_OP_SUBSCRIBE, RPC_OP_SUBSCRIBE_REPLY, + h, suunto_nautic_noparams, sizeof (suunto_nautic_noparams), reply, sizeof (reply), &reply_len); + if (status != DC_STATUS_SUCCESS) + return status; - progress.current = 1; - device_event_emit (abstract, DC_EVENT_PROGRESS, &progress); + result = array_uint16_le (reply + REPLY_STATUS); + if (result != STATUS_OK) { + ERROR (abstract->context, "The watch refused the subscription to %s (status %u).", path, result); + return DC_STATUS_PROTOCOL; + } - // 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) { - ERROR (abstract->context, "Failed to fetch /Logbook/Entries."); - return status; + if (value) { + dc_buffer_clear (value); + if (!dc_buffer_append (value, reply + REPLY_CONTENT, reply_len - REPLY_CONTENT)) + return DC_STATUS_NOMEMORY; } - unsigned int count = entries.count; - progress.maximum = (count + 1) * 2; - device_event_emit (abstract, DC_EVENT_PROGRESS, &progress); + return status; +} - dc_buffer_t *raw = dc_buffer_new (0); - if (raw == NULL) { - suunto_nautic_entries_free (&entries); - return DC_STATUS_NOMEMORY; - } +dc_status_t +suunto_nautic_device_unsubscribe (dc_device_t *abstract, const unsigned char handle[3]) +{ + if (!suunto_nautic_is_device (abstract) || handle == NULL) + return DC_STATUS_INVALIDARGS; - for (unsigned int i = 0; i < count; i++) { - unsigned char fingerprint[4]; - array_uint32_le_set (fingerprint, entries.ids[i]); + suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract; + unsigned int h = array_uint24_le (handle); - // 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; + unsigned char reply[MAX_PAYLOAD] = {0}; + size_t reply_len = 0; + dc_status_t status = suunto_nautic_transfer (device, RPC_OP_UNSUBSCRIBE, RPC_OP_UNSUBSCRIBE_REPLY, + h, suunto_nautic_noparams, sizeof (suunto_nautic_noparams), reply, sizeof (reply), &reply_len); + if (status != DC_STATUS_SUCCESS) + return status; - char logbook_id[16]; - int n = snprintf (logbook_id, sizeof (logbook_id), "%u", entries.ids[i]); - if (n < 0 || (size_t) n >= sizeof (logbook_id)) - continue; + suunto_nautic_release (device, h); + return status; +} - dc_buffer_clear (raw); - int incomplete = 0, unreadable = 0; - status = suunto_nautic_device_download_dive (abstract, logbook_id, entries.sizes[i], raw, &incomplete, &unreadable); - if (status != DC_STATUS_SUCCESS && unreadable) { - // An empty/aborted entry: listed, but with nothing to decode. - WARNING (abstract->context, "Skipping logbook entry %s (no decodable dive data).", logbook_id); - status = DC_STATUS_SUCCESS; - continue; - } - if (status != DC_STATUS_SUCCESS) { - // Stop at the first dive that may download later: the host takes - // its fingerprint from the newest dive, so carrying on past a gap - // would mark this dive as synced. - ERROR (abstract->context, "Failed to download logbook entry %s; it and any older new dives are left for the next sync.", - logbook_id); - break; - } +dc_status_t +suunto_nautic_device_wait_notification (dc_device_t *abstract, unsigned int timeout, unsigned char handle[3], dc_buffer_t *value) +{ + if (!suunto_nautic_is_device (abstract) || handle == NULL || value == NULL) + return DC_STATUS_INVALIDARGS; - // 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); + suunto_nautic_device_t *device = (suunto_nautic_device_t *) abstract; - progress.current += 2; - device_event_emit (abstract, DC_EVENT_PROGRESS, &progress); + if (device_is_cancelled (abstract)) + return DC_STATUS_CANCELLED; - if (callback && !callback (dc_buffer_get_data (raw), (unsigned int) dc_buffer_get_size (raw), - fingerprint, sizeof (fingerprint), userdata)) - break; + dc_status_t status = dc_iostream_set_timeout (device->iostream, (int) timeout); + if (status != DC_STATUS_SUCCESS) + return status; + + // A value notification is HANDLE(3) FLAGS(3) TYPE(2) VALUE. + unsigned char data[MAX_PAYLOAD] = {0}; + size_t size = 0; + status = suunto_nautic_recv (device, RPC_OP_NOTIFICATION, 0, data, sizeof (data), &size); + if (status == DC_STATUS_SUCCESS && size < 6) { + ERROR (abstract->context, "Unexpected notification size (" DC_PRINTF_SIZE ").", size); + status = DC_STATUS_PROTOCOL; + } + if (status == DC_STATUS_SUCCESS) { + memcpy (handle, data, 3); + dc_buffer_clear (value); + if (!dc_buffer_append (value, data + 6, size - 6)) + status = DC_STATUS_NOMEMORY; } - dc_buffer_free (raw); - suunto_nautic_entries_free (&entries); + dc_status_t rc = dc_iostream_set_timeout (device->iostream, TIMEOUT_DEFAULT); + if (status == DC_STATUS_SUCCESS) + status = rc; return status; } diff --git a/src/suunto_nautic.h b/src/suunto_nautic.h index fb73c38..ffe2d65 100644 --- a/src/suunto_nautic.h +++ b/src/suunto_nautic.h @@ -25,25 +25,27 @@ * 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. + * - Every packet is 0xA5, opcode, length(u16 LE), seqnum(u16 LE), + * payload(length), crc32(u32 LE), where crc32 is the reflected CRC-32 + * (checksum_crc32r) of every preceding byte. A reply echoes the + * request's sequence number. The watch serves every app connected to + * it over the same link, so replies to another app's requests are + * recognized by their sequence number and skipped. + * - A GET (0x0A) resolves a path to a handle; the other requests (FETCH + * 0x0D, SUBSCRIBE 0x10, UNSUBSCRIBE 0x11, RELEASE 0x0B) address that + * handle. A per-dive handle stays bound until it is released. * * Dive download: - * - GET /Data (opcode 0x0A) -> ack carrying the handle bound to - * that dive -> subscribe on that handle (0x10) -> chunk stream (opcode - * 0x01, repeated). The chunk stream is unacknowledged and continuous; - * the host buffers until a 2.0s silence timeout, then closes it with - * STREAM_STOP (opcode 0x11) and releases the handle (opcode 0x0B), as - * a still-bound handle makes the next GET of /Data bind another slot. - * - 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 + * - GET /Logbook/byId//Data, SUBSCRIBE: the watch streams the + * compressed dive in unacknowledged notifications (0x01), each with + * its offset in the stream, and ends with a zero length chunk at the + * total size. Then UNSUBSCRIBE and RELEASE. + * - The stream is Heatshrink-compressed (LZSS variant, see * src/heatshrink/; window_sz2 = 7, lookahead_sz2 = 5). The decoded * stream is an SBEM0103 container. + * - GET /Logbook/byId//Summary, FETCH page by page: an uncompressed + * SBEM0103 container with the fields that are not in the profile + * (gradient factors, gas mixes). It is appended to the profile. * * SBEM0103 container: * - A numeric Type-Length-Value stream: [id: 1 byte][length: 1 byte] @@ -59,15 +61,10 @@ * 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. 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. + * - /Logbook/Entries lists the dives, oldest first and in pages. Each + * record holds the dive id (its start time, a UNIX timestamp) and its + * size, which is checked against the download. 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