From 4f2a61065fadcc7e1760d5e75514f4698823b4e8 Mon Sep 17 00:00:00 2001 From: trustthegoose <193519572+trustthegoose@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:25:35 -0400 Subject: [PATCH 1/7] suunto_nautic: skip another app's subscription notifications in the /Data stream The Suunto app on the same phone holds subscriptions whose pushed values arrive as 0x01 notifications with sequence number 0, like our stream chunks (one seen on handle F0 BF 06 with a 4-byte payload). The stream loop took them as chunks and failed the stream with "Unexpected chunk size". Skip notifications whose handle isn't the stream's, comparing without the slot byte (chunks carry slot F0, the GET reply slot 00). --- src/suunto_nautic.c | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/suunto_nautic.c b/src/suunto_nautic.c index 9dcb78f..5c1e033 100644 --- a/src/suunto_nautic.c +++ b/src/suunto_nautic.c @@ -633,6 +633,16 @@ suunto_nautic_stream (suunto_nautic_device_t *device, const char *path, dc_buffe break; } + // Another app's subscriptions (the Suunto app holds several) push + // their values on the same link, as notifications with the same + // opcode and sequence number as our chunks but a different handle. + // Compare without the slot byte: our chunks carry the handle with + // slot F0, the GET reply with slot 00. + if (chunk_len >= 3 && (array_uint24_le (chunk) & ~0xFFu) != (handle & ~0xFFu)) { + DEBUG (abstract->context, "Skipping a notification for another subscription (%06x).", array_uint24_le (chunk)); + continue; + } + if (chunk_len < CHUNK_DATA) { ERROR (abstract->context, "Unexpected chunk size (" DC_PRINTF_SIZE ").", chunk_len); status = DC_STATUS_PROTOCOL; From d5746badf4e93f386d732c7b87324a625d4cea06 Mon Sep 17 00:00:00 2001 From: trustthegoose <193519572+trustthegoose@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:04:45 -0400 Subject: [PATCH 2/7] suunto_nautic: step aside for another app on the shared link and resume When the Suunto app opens its own session on the phone's shared BLE link, the watch answers its hello and ends our /Data stream: a torn chunk (the hello reply is usually swallowed with it) or UNSUBSCRIBE_REPLY with a non-200 status. Until now that truncated the dive or failed the download. On any of those during a stream, mark the link contended and, instead of unsubscribing into the other app's session, step aside: emit DC_EVENT_WAITING, read and discard packets until the link has been quiet for 15 s (longer than the Suunto app's ~10.6 s hello retry, so it gets in; capped at an hour, cancellable), then stop and release the old stream (or the restart is refused with 423) and restart the dive from the beginning. No new hello on resume: the Suunto app answers a hello by re-opening its own session, which would end our stream again. A 423 after a pause means the other app is still streaming, so pause again. Pauses don't use up retries; at most 5 per dive. A hello reply seen during an ordinary request just keeps us waiting for our own reply, as before. Before our own hello, listen 1.5 s and wait for quiet if another app is mid-sync. Part 2 of deepsealabs/libdc-swift#78. --- src/suunto_nautic.c | 204 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 197 insertions(+), 7 deletions(-) diff --git a/src/suunto_nautic.c b/src/suunto_nautic.c index 5c1e033..321195e 100644 --- a/src/suunto_nautic.c +++ b/src/suunto_nautic.c @@ -114,6 +114,19 @@ #define STREAM_ATTEMPTS 3 #define DOWNLOAD_ATTEMPTS 2 +// Coexisting with another app (the Suunto app) on the shared BLE link. When the +// other app opens its own session, the watch answers its hello on the link we +// share and ends our stream (a torn chunk, or UNSUBSCRIBE_REPLY with status +// 404). The driver then steps aside: it stops sending, reads until the other +// app's traffic has been quiet for YIELD_QUIET_MS, and restarts the dive +// without a new hello (the Suunto app answers a hello by re-opening its own +// session, a ping-pong). Our session survives the other app's hello. +#define YIELD_POLL_MS 1000 +#define YIELD_QUIET_MS 15000 // longer than the Suunto app's ~10.6 s hello retry, so it gets in +#define YIELD_MAX_S 3600 // wait as long as the other app stays busy (cancellable); quiet ends it sooner +#define MAX_YIELDS 5 +#define OPEN_LISTEN_MS 1500 + #define TIMEOUT_DEFAULT 5000 #define TIMEOUT_STREAM 4000 // the gap between two chunks reaches about 2s @@ -138,6 +151,15 @@ typedef struct suunto_nautic_device_t { // The dive id (a UNIX timestamp) of the most recently downloaded dive, // little-endian. All-zero means no fingerprint. unsigned char fingerprint[4]; + // Another app opened a session on the shared link while we were + // streaming; cleared once we've stepped aside. + int contended; + // The handle of a stream left open by a takeover, stopped and released + // once the link is quiet again (0 = none). + unsigned int stale_handle; + // Set once a download has stepped aside for another app: a 423 (Locked) + // after that means the other app is still streaming, so pause again. + int yielded; } suunto_nautic_device_t; // Dive ids and their listed sizes, newest first. @@ -151,6 +173,7 @@ typedef struct 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); +static dc_status_t suunto_nautic_wait_quiet (suunto_nautic_device_t *device, unsigned int *packets); static const dc_device_vtable_t suunto_nautic_device_vtable = { sizeof(suunto_nautic_device_t), @@ -259,8 +282,14 @@ suunto_nautic_recv (suunto_nautic_device_t *device, unsigned char opcode, unsign continue; } + // While streaming, a packet cut short is the watch abandoning our + // chunk mid-frame to answer another app's hello. That hello reply is + // usually lost with it: its opening flag closed the torn packet. + int streaming = opcode == RPC_OP_NOTIFICATION; + if (length < RPC_OVERHEAD) { ERROR (device->base.context, "Unexpected packet size (" DC_PRINTF_SIZE ").", length); + device->contended |= streaming; return DC_STATUS_PROTOCOL; } @@ -268,6 +297,7 @@ suunto_nautic_recv (suunto_nautic_device_t *device, unsigned char opcode, unsign unsigned int ccrc = checksum_crc32r (packet, length - 4); if (crc != ccrc) { ERROR (device->base.context, "Unexpected packet checksum (%08x %08x).", crc, ccrc); + device->contended |= streaming; return DC_STATUS_PROTOCOL; } @@ -279,12 +309,32 @@ suunto_nautic_recv (suunto_nautic_device_t *device, unsigned char opcode, unsign 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); + device->contended |= streaming; return DC_STATUS_PROTOCOL; } unsigned int rsp_opcode = packet[1]; unsigned int rsp_seqnum = array_uint16_le (packet + 4); if (rsp_opcode != opcode || rsp_seqnum != seqnum) { + // Another app said hello: its session now has the link. A + // request keeps waiting for its reply (the watch still answers + // it); a stream is about to be ended, so give it up. + if (rsp_opcode == RPC_OP_HELLO_REPLY && opcode != RPC_OP_HELLO_REPLY) { + WARNING (device->base.context, "Another app opened a session on the link."); + if (streaming) { + device->contended = 1; + return DC_STATUS_PROTOCOL; + } + continue; + } + // The watch ended our stream itself (status 404) for that session. + if (streaming && rsp_opcode == RPC_OP_UNSUBSCRIBE_REPLY && rsp_length >= REPLY_CONTENT && + array_uint16_le (packet + 6 + REPLY_STATUS) != STATUS_OK) { + WARNING (device->base.context, "The watch ended the stream (status %u).", + array_uint16_le (packet + 6 + REPLY_STATUS)); + device->contended = 1; + return DC_STATUS_PROTOCOL; + } if (rsp_opcode == RPC_OP_NOTIFICATION) { // A leftover chunk of a stream that was stopped. DEBUG (device->base.context, "Skipping a stream notification."); @@ -365,6 +415,9 @@ suunto_nautic_device_open (dc_device_t **out, dc_context_t *context, dc_iostream device->seqnum = g_suunto_nautic_seqnum; memset (device->fingerprint, 0, sizeof (device->fingerprint)); device->acknowledged = 0; + device->contended = 0; + device->stale_handle = 0; + device->yielded = 0; status = dc_hdlc_open (&device->iostream, context, iostream, 244, 244); if (status != DC_STATUS_SUCCESS) { @@ -380,6 +433,28 @@ suunto_nautic_device_open (dc_device_t **out, dc_context_t *context, dc_iostream dc_iostream_purge (device->iostream, DC_DIRECTION_ALL); + // Another app (the Suunto app) may already be using the watch on this + // shared link. Listen briefly before the hello: if it is mid-sync, wait + // until its traffic stops instead of cutting into it. (The host can't be + // told yet: events are only wired up after open returns.) + if (dc_iostream_set_timeout (device->iostream, OPEN_LISTEN_MS) == DC_STATUS_SUCCESS) { + unsigned char probe[RPC_OVERHEAD + MAX_PAYLOAD] = {0}; + size_t probe_len = 0; + if (dc_iostream_read (device->iostream, probe, sizeof (probe), &probe_len) == DC_STATUS_SUCCESS && probe_len) { + INFO (context, "Another app is using the watch; waiting until it is done before the hello."); + unsigned int packets = 0; + status = suunto_nautic_wait_quiet (device, &packets); + if (status != DC_STATUS_SUCCESS) + goto error_close; + INFO (context, "The link is quiet (%u more packets from the other app).", packets); + } + } + status = dc_iostream_set_timeout (device->iostream, TIMEOUT_DEFAULT); + if (status != DC_STATUS_SUCCESS) { + ERROR (context, "Failed to set the timeout."); + goto error_close; + } + 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 hello."); @@ -600,7 +675,14 @@ suunto_nautic_stream (suunto_nautic_device_t *device, const char *path, dc_buffe 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); + // After stepping aside once, 423 (Locked) means the other app is still + // streaming its own dives: wait for it again rather than fail. + if (result == 423 && device->yielded) { + WARNING (abstract->context, "The watch is still busy with another app (status 423); pausing again."); + device->contended = 1; + } else { + ERROR (abstract->context, "The watch refused the stream for %s (status %u).", path, result); + } status = DC_STATUS_PROTOCOL; goto error_release; } @@ -680,11 +762,14 @@ suunto_nautic_stream (suunto_nautic_device_t *device, const char *path, dc_buffe 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); + // stream is refused with status 423. After another app took over, our + // stream is already gone and the request would land in its session. + if (!device->contended) { + 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) { @@ -693,10 +778,102 @@ suunto_nautic_stream (suunto_nautic_device_t *device, const char *path, dc_buffe } error_release: - suunto_nautic_release (device, handle); + if (!device->contended) + suunto_nautic_release (device, handle); + else + device->stale_handle = handle; return status; } +// Read and discard packets until the link has been quiet for YIELD_QUIET_MS +// (or YIELD_MAX_S has passed): the other app's traffic, and the leftovers of +// the stream the watch cut short. Returns the number of packets seen. +static dc_status_t +suunto_nautic_wait_quiet (suunto_nautic_device_t *device, unsigned int *packets) +{ + dc_device_t *abstract = (dc_device_t *) device; + dc_status_t status = DC_STATUS_SUCCESS; + + *packets = 0; + status = dc_iostream_set_timeout (device->iostream, YIELD_POLL_MS); + if (status != DC_STATUS_SUCCESS) + return status; + + dc_ticks_t start = dc_datetime_now (); + unsigned int quiet = 0; + while (quiet < YIELD_QUIET_MS) { + if (device_is_cancelled (abstract)) { + status = DC_STATUS_CANCELLED; + break; + } + if (dc_datetime_now () - start >= YIELD_MAX_S) { + WARNING (abstract->context, "The link is still busy after %u s; carrying on.", YIELD_MAX_S); + break; + } + unsigned char packet[RPC_OVERHEAD + MAX_PAYLOAD] = {0}; + size_t length = 0; + dc_status_t rc = dc_iostream_read (device->iostream, packet, sizeof (packet), &length); + if (rc == DC_STATUS_TIMEOUT) { + quiet += YIELD_POLL_MS; + continue; + } + if (rc != DC_STATUS_SUCCESS) { + status = rc; + break; + } + if (length) { + (*packets)++; + quiet = 0; + } + } + + dc_status_t rc = dc_iostream_set_timeout (device->iostream, TIMEOUT_DEFAULT); + if (rc != DC_STATUS_SUCCESS) + dc_status_set_error (&status, rc); + return status; +} + +// Step aside for another app: tell the host (a WAITING event, e.g. "paused: +// the Suunto app is syncing") and wait until the other app's traffic stops. +// No hello afterwards (see YIELD_QUIET_MS). `*yields` caps the pauses per dive. +static dc_status_t +suunto_nautic_yield (suunto_nautic_device_t *device, unsigned int *yields) +{ + dc_device_t *abstract = (dc_device_t *) device; + + device->contended = 0; + if (++*yields > MAX_YIELDS) { + ERROR (abstract->context, "Another app took over the watch %u times; giving up.", MAX_YIELDS); + return DC_STATUS_TIMEOUT; + } + + device->yielded = 1; + INFO (abstract->context, "Another app is using the watch; pausing until it is done (pause %u/%u).", + *yields, MAX_YIELDS); + device_event_emit (abstract, DC_EVENT_WAITING, NULL); + + unsigned int packets = 0; + dc_status_t status = suunto_nautic_wait_quiet (device, &packets); + if (status != DC_STATUS_SUCCESS) + return status; + + // The takeover may not have ended our stream on the watch: stop and + // release it now, or the restart is refused with 423 (Locked). Best + // effort: if the watch already ended it, these just fail. + if (device->stale_handle) { + unsigned char reply[REPLY_CONTENT + 2] = {0}; + size_t reply_len = 0; + if (suunto_nautic_transfer (device, RPC_OP_UNSUBSCRIBE, RPC_OP_UNSUBSCRIBE_REPLY, device->stale_handle, + suunto_nautic_noparams, sizeof (suunto_nautic_noparams), reply, sizeof (reply), &reply_len) != DC_STATUS_SUCCESS) + DEBUG (abstract->context, "No reply to the stop of the interrupted stream."); + suunto_nautic_release (device, device->stale_handle); + device->stale_handle = 0; + } + + INFO (abstract->context, "The link is quiet again (%u packets from the other app); resuming.", packets); + return DC_STATUS_SUCCESS; +} + // 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 @@ -972,6 +1149,7 @@ suunto_nautic_download (suunto_nautic_device_t *device, unsigned int id, unsigne goto error_free; } + unsigned int yields = 0; for (unsigned int attempt = 0; attempt < DOWNLOAD_ATTEMPTS; attempt++) { for (unsigned int i = 0; i < STREAM_ATTEMPTS; i++) { if (i > 0) { @@ -979,6 +1157,18 @@ suunto_nautic_download (suunto_nautic_device_t *device, unsigned int id, unsigne dc_iostream_sleep (device->iostream, 1500 * i); } status = suunto_nautic_stream (device, data_path, compressed); + // Taken over mid-stream: step aside, then restart this dive's + // stream from the beginning (it can't resume partway). A pause + // doesn't use up a retry. + if (device->contended) { + WARNING (abstract->context, "Stream of dive %u interrupted after " DC_PRINTF_SIZE " bytes; restarting it after the pause.", + id, dc_buffer_get_size (compressed)); + status = suunto_nautic_yield (device, &yields); + if (status != DC_STATUS_SUCCESS) + goto error_free; + i = (unsigned int) -1; // the increment brings it back to 0 + continue; + } if (status != DC_STATUS_PROTOCOL) break; } From cf5be2e843221a6ef0040c1b43503f5ce549c78a Mon Sep 17 00:00:00 2001 From: trustthegoose <193519572+trustthegoose@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:49:10 -0400 Subject: [PATCH 3/7] suunto_nautic: notice another app re-pointing the shared /Data stream handle The watch has one handle for streaming a dive's /Data, shared by every client on the link. Another app's GET of a dive's /Data re-points it, so our subscription then carries that app's dive, and its UNSUBSCRIBE ends our stream. Seen with a second client streaming dives back to back: one dive came out truncated with the other client's data in it, caught only by the listed-size check. While streaming, treat another client's GET or SUBSCRIBE reply on our stream's handle (ignoring the slot byte) as a takeover. After a pause, a size mismatch also means the other app's traffic got into our stream: pause again and redo the dive instead of keeping it truncated. --- src/suunto_nautic.c | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/suunto_nautic.c b/src/suunto_nautic.c index 321195e..b0b9eb6 100644 --- a/src/suunto_nautic.c +++ b/src/suunto_nautic.c @@ -160,6 +160,11 @@ typedef struct suunto_nautic_device_t { // Set once a download has stepped aside for another app: a 423 (Locked) // after that means the other app is still streaming, so pause again. int yielded; + // The handle of the stream in progress (0 = none). The watch has one + // handle for streaming a dive's /Data, shared by every client: another + // app's GET of a dive's /Data re-points it, and our subscription then + // carries that app's dive. + unsigned int stream_handle; } suunto_nautic_device_t; // Dive ids and their listed sizes, newest first. @@ -327,6 +332,15 @@ suunto_nautic_recv (suunto_nautic_device_t *device, unsigned char opcode, unsign } continue; } + // Another app bound our stream's handle to its own dive: from now + // on our subscription carries its data. + if (streaming && device->stream_handle && rsp_length >= 3 && + (rsp_opcode == RPC_OP_GET_REPLY || rsp_opcode == RPC_OP_SUBSCRIBE_REPLY) && + (array_uint24_le (packet + 6) & ~0xFFu) == (device->stream_handle & ~0xFFu)) { + WARNING (device->base.context, "Another app took over the stream's handle (%06x).", array_uint24_le (packet + 6)); + device->contended = 1; + return DC_STATUS_PROTOCOL; + } // The watch ended our stream itself (status 404) for that session. if (streaming && rsp_opcode == RPC_OP_UNSUBSCRIBE_REPLY && rsp_length >= REPLY_CONTENT && array_uint16_le (packet + 6 + REPLY_STATUS) != STATUS_OK) { @@ -418,6 +432,7 @@ suunto_nautic_device_open (dc_device_t **out, dc_context_t *context, dc_iostream device->contended = 0; device->stale_handle = 0; device->yielded = 0; + device->stream_handle = 0; status = dc_hdlc_open (&device->iostream, context, iostream, 244, 244); if (status != DC_STATUS_SUCCESS) { @@ -687,6 +702,7 @@ suunto_nautic_stream (suunto_nautic_device_t *device, const char *path, dc_buffe goto error_release; } + device->stream_handle = handle; status = dc_iostream_set_timeout (device->iostream, TIMEOUT_STREAM); if (status != DC_STATUS_SUCCESS) { ERROR (abstract->context, "Failed to set the timeout."); @@ -778,6 +794,7 @@ suunto_nautic_stream (suunto_nautic_device_t *device, const char *path, dc_buffe } error_release: + device->stream_handle = 0; if (!device->contended) suunto_nautic_release (device, handle); else @@ -1197,6 +1214,17 @@ suunto_nautic_download (suunto_nautic_device_t *device, unsigned int id, unsigne WARNING (abstract->context, "Incomplete download of dive %u (" DC_PRINTF_SIZE " + " DC_PRINTF_SIZE " bytes, listed %u).", id, data_size, summary_size, listed); + + // After another app has had the watch, a wrong size most likely means + // its traffic got into our stream: step aside again and redo the dive + // rather than keep a truncated or mixed one. + if (device->yielded) { + status = suunto_nautic_yield (device, &yields); + if (status != DC_STATUS_SUCCESS) + goto error_free; + attempt = (unsigned int) -1; // the increment brings it back to 0 + continue; + } } status = suunto_nautic_heatshrink_decompress (abstract->context, From 95cf871635ca76685fee990a2e3ecc4b8ba87f62 Mon Sep 17 00:00:00 2001 From: trustthegoose <193519572+trustthegoose@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:12:19 -0400 Subject: [PATCH 4/7] suunto_nautic: don't count pauses for another app's dive downloads When another app re-points the shared /Data stream handle, it is downloading dives itself. Wait that out without counting it toward the pause cap, so an app fetching many dives in a row is waited out in full. Every other pause (a fresh hello, a 423, a torn stream) still counts, so an app stuck reconnecting still ends the download after 5 pauses. --- src/suunto_nautic.c | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/suunto_nautic.c b/src/suunto_nautic.c index b0b9eb6..fd4cb60 100644 --- a/src/suunto_nautic.c +++ b/src/suunto_nautic.c @@ -165,6 +165,9 @@ typedef struct suunto_nautic_device_t { // app's GET of a dive's /Data re-points it, and our subscription then // carries that app's dive. unsigned int stream_handle; + // The takeover was another app binding the stream handle to its own + // dive, i.e. it is downloading dives: wait it out, don't count it. + int rebound; } suunto_nautic_device_t; // Dive ids and their listed sizes, newest first. @@ -339,6 +342,7 @@ suunto_nautic_recv (suunto_nautic_device_t *device, unsigned char opcode, unsign (array_uint24_le (packet + 6) & ~0xFFu) == (device->stream_handle & ~0xFFu)) { WARNING (device->base.context, "Another app took over the stream's handle (%06x).", array_uint24_le (packet + 6)); device->contended = 1; + device->rebound = 1; return DC_STATUS_PROTOCOL; } // The watch ended our stream itself (status 404) for that session. @@ -433,6 +437,7 @@ suunto_nautic_device_open (dc_device_t **out, dc_context_t *context, dc_iostream device->stale_handle = 0; device->yielded = 0; device->stream_handle = 0; + device->rebound = 0; status = dc_hdlc_open (&device->iostream, context, iostream, 244, 244); if (status != DC_STATUS_SUCCESS) { @@ -859,14 +864,22 @@ suunto_nautic_yield (suunto_nautic_device_t *device, unsigned int *yields) dc_device_t *abstract = (dc_device_t *) device; device->contended = 0; - if (++*yields > MAX_YIELDS) { + // Another app downloading dives (it re-pointed our stream handle) is a + // deliberate sync: wait it out however many dives it takes. The cap is + // for everything else, e.g. an app stuck reconnecting. + int counted = !device->rebound; + device->rebound = 0; + if (counted && ++*yields > MAX_YIELDS) { ERROR (abstract->context, "Another app took over the watch %u times; giving up.", MAX_YIELDS); return DC_STATUS_TIMEOUT; } device->yielded = 1; - INFO (abstract->context, "Another app is using the watch; pausing until it is done (pause %u/%u).", - *yields, MAX_YIELDS); + if (counted) + INFO (abstract->context, "Another app is using the watch; pausing until it is done (pause %u/%u).", + *yields, MAX_YIELDS); + else + INFO (abstract->context, "Another app is downloading dives; pausing until it is done (not counted)."); device_event_emit (abstract, DC_EVENT_WAITING, NULL); unsigned int packets = 0; From f98db6a7296269c7ec249fb05f93b1bdceba514e Mon Sep 17 00:00:00 2001 From: trustthegoose <193519572+trustthegoose@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:30:09 -0400 Subject: [PATCH 5/7] hdlc: skip empty frames instead of losing sync Back-to-back frames share no flag: one frame's closing 0x7E is followed by the next one's opening 0x7E. A reader that starts in the middle of a frame takes the closing flag as an opening one, returns an empty frame at the next flag, and then starts each later read inside a frame again. It stays out of sync for as long as the frames keep coming back to back, so every read returns an empty frame and every packet is lost. Seen on the Suunto Nautic when another client is streaming a dive on the shared link: our reader sees nothing for the whole stream, including any reply meant for us. Treat two flags in a row as the start of the next frame, as HDLC readers usually do; an empty frame carries nothing. --- src/hdlc.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/hdlc.c b/src/hdlc.c index 40e1ae7..3b114f9 100644 --- a/src/hdlc.c +++ b/src/hdlc.c @@ -233,7 +233,10 @@ dc_hdlc_read (dc_iostream_t *abstract, void *data, size_t size, size_t *actual) goto out; } - if (initialized) { + // Two flags in a row (one frame's closing flag, the next one's + // opening flag) delimit no frame: stay in sync and start the + // next frame here, rather than return an empty one. + if (initialized && nbytes) { goto out; } From 37ea43272198c50c85793ba8622c51766bc4f8dc Mon Sep 17 00:00:00 2001 From: trustthegoose <193519572+trustthegoose@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:00:53 -0400 Subject: [PATCH 6/7] suunto_nautic: use another app's session instead of saying hello over it Before the hello, send a GET without one. The watch answers it only when another client already has a session on the link; on a fresh link it stays silent, and then we say hello as before. With an existing session, skip our hello: it would take that session over, and a Suunto app that doesn't answer it with a hello of its own keeps sending requests but stops getting their replies. It then reports the sync done without the new dives until it is restarted. Seen on both watches with the app mid-sync; on one of them the listen before the hello heard nothing, so only a request can tell. The watch doesn't answer a client without a session of its own while another client's stream is running, so if other traffic arrived while waiting for the reply, wait for quiet and ask again. --- src/suunto_nautic.c | 48 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/suunto_nautic.c b/src/suunto_nautic.c index fd4cb60..2a46035 100644 --- a/src/suunto_nautic.c +++ b/src/suunto_nautic.c @@ -126,6 +126,12 @@ #define YIELD_MAX_S 3600 // wait as long as the other app stays busy (cancellable); quiet ends it sooner #define MAX_YIELDS 5 #define OPEN_LISTEN_MS 1500 +// Before our own hello, a request without one: the watch answers it only when +// another app already has a session on the link (a fresh link stays silent). +// Then we use that session: a hello of ours would take it over, and a Suunto +// app that doesn't answer with a hello of its own stops getting its replies. +#define OPEN_PROBE_MS 1500 +#define OPEN_PROBE_PATH "/Logbook/Entries" #define TIMEOUT_DEFAULT 5000 #define TIMEOUT_STREAM 4000 // the gap between two chunks reaches about 2s @@ -168,6 +174,8 @@ typedef struct suunto_nautic_device_t { // The takeover was another app binding the stream handle to its own // dive, i.e. it is downloading dives: wait it out, don't count it. int rebound; + // Packets for other requests or streams skipped since last cleared. + unsigned int skipped; } suunto_nautic_device_t; // Dive ids and their listed sizes, newest first. @@ -329,6 +337,7 @@ suunto_nautic_recv (suunto_nautic_device_t *device, unsigned char opcode, unsign // it); a stream is about to be ended, so give it up. if (rsp_opcode == RPC_OP_HELLO_REPLY && opcode != RPC_OP_HELLO_REPLY) { WARNING (device->base.context, "Another app opened a session on the link."); + device->skipped++; if (streaming) { device->contended = 1; return DC_STATUS_PROTOCOL; @@ -353,6 +362,7 @@ suunto_nautic_recv (suunto_nautic_device_t *device, unsigned char opcode, unsign device->contended = 1; return DC_STATUS_PROTOCOL; } + device->skipped++; if (rsp_opcode == RPC_OP_NOTIFICATION) { // A leftover chunk of a stream that was stopped. DEBUG (device->base.context, "Skipping a stream notification."); @@ -410,6 +420,8 @@ suunto_nautic_transfer (suunto_nautic_device_t *device, unsigned char opcode, un return status; } +static dc_status_t suunto_nautic_get (suunto_nautic_device_t *device, const char *path, unsigned int *handle, unsigned int *result); + dc_status_t suunto_nautic_device_open (dc_device_t **out, dc_context_t *context, dc_iostream_t *iostream) { @@ -438,6 +450,7 @@ suunto_nautic_device_open (dc_device_t **out, dc_context_t *context, dc_iostream device->yielded = 0; device->stream_handle = 0; device->rebound = 0; + device->skipped = 0; status = dc_hdlc_open (&device->iostream, context, iostream, 244, 244); if (status != DC_STATUS_SUCCESS) { @@ -469,12 +482,47 @@ suunto_nautic_device_open (dc_device_t **out, dc_context_t *context, dc_iostream INFO (context, "The link is quiet (%u more packets from the other app).", packets); } } + status = dc_iostream_set_timeout (device->iostream, OPEN_PROBE_MS); + if (status != DC_STATUS_SUCCESS) { + ERROR (context, "Failed to set the timeout."); + goto error_close; + } + // The watch doesn't answer it while another client's stream is running: + // if other traffic arrived meanwhile, wait for quiet and ask again. + int shared = 0; + for (unsigned int round = 0; round < 2 && !shared; round++) { + unsigned int probe_handle = 0, probe_result = 0; + device->skipped = 0; + shared = suunto_nautic_get (device, OPEN_PROBE_PATH, &probe_handle, &probe_result) == DC_STATUS_SUCCESS; + if (shared || device->skipped == 0) + break; + INFO (context, "Another app is using the watch; waiting until it is done."); + unsigned int packets = 0; + status = suunto_nautic_wait_quiet (device, &packets); + if (status != DC_STATUS_SUCCESS) + goto error_close; + status = dc_iostream_set_timeout (device->iostream, OPEN_PROBE_MS); + if (status != DC_STATUS_SUCCESS) { + ERROR (context, "Failed to set the timeout."); + goto error_close; + } + } + status = dc_iostream_set_timeout (device->iostream, TIMEOUT_DEFAULT); if (status != DC_STATUS_SUCCESS) { ERROR (context, "Failed to set the timeout."); goto error_close; } + if (!shared) { + INFO (context, "No other session on the link (the request above went unanswered, as expected); saying hello."); + } else { + INFO (context, "Another app has a session on the link; using it instead of a hello of our own."); + device->acknowledged = 1; + *out = (dc_device_t *) device; + return DC_STATUS_SUCCESS; + } + 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 hello."); From e38d304de1bdfde57d46683f3af7f0b03ab38fc0 Mon Sep 17 00:00:00 2001 From: trustthegoose <193519572+trustthegoose@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:01:11 -0400 Subject: [PATCH 7/7] suunto_nautic: treat any other client's reply on our stream handle as a takeover Another client's UNSUBSCRIBE (status 200) and FETCH on the shared /Data handle also end or disturb our stream, not only its GET and SUBSCRIBE. Seen with the Suunto app on an Ocean: its UNSUBSCRIBE reply ended our stream silently ('ended without an end marker'), and the dive was only saved because the immediate retry happened to land after the app was done. --- src/suunto_nautic.c | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/suunto_nautic.c b/src/suunto_nautic.c index 2a46035..b0cd5c1 100644 --- a/src/suunto_nautic.c +++ b/src/suunto_nautic.c @@ -344,10 +344,11 @@ suunto_nautic_recv (suunto_nautic_device_t *device, unsigned char opcode, unsign } continue; } - // Another app bound our stream's handle to its own dive: from now - // on our subscription carries its data. + // Another app is using our stream's handle: a GET or SUBSCRIBE + // binds it to its own dive (our subscription then carries its + // data), an UNSUBSCRIBE ends our stream, a FETCH reads through it. if (streaming && device->stream_handle && rsp_length >= 3 && - (rsp_opcode == RPC_OP_GET_REPLY || rsp_opcode == RPC_OP_SUBSCRIBE_REPLY) && + rsp_opcode != RPC_OP_NOTIFICATION && rsp_opcode != RPC_OP_HELLO_REPLY && (array_uint24_le (packet + 6) & ~0xFFu) == (device->stream_handle & ~0xFFu)) { WARNING (device->base.context, "Another app took over the stream's handle (%06x).", array_uint24_le (packet + 6)); device->contended = 1;