Skip to content

suunto_nautic: step aside for another app on the shared link and resume - #16

Open
trustthegoose wants to merge 7 commits into
deepsealabs:mainfrom
trustthegoose:nautic-coexist-pause-resume
Open

trustthegoose wants to merge 7 commits into
deepsealabs:mainfrom
trustthegoose:nautic-coexist-pause-resume

Conversation

@trustthegoose

Copy link
Copy Markdown

Part 2 of deepsealabs/libdc-swift#78. Stacked on #15: this branch contains #15's commit too, so please merge #15 first; after that only the second commit (d5746ba) is new here.

Problem

On the same phone, DC Tester and the Suunto app share one BLE link to the watch. When the Suunto app opens a session (a fresh launch, a reconnect), the watch answers its hello on that link and ends our /Data stream: either a chunk is cut off mid-frame (the hello reply is usually swallowed with it in hdlc.c) or UNSUBSCRIBE_REPLY arrives with status 404. Today that truncates the dive or fails the download, which is why testers are told to block the Suunto app's Bluetooth.

What this does

  • Detects the takeover while streaming: a hello reply we didn't ask for, a non-200 UNSUBSCRIBE_REPLY before we unsubscribed, or a torn / CRC-bad packet. Ordinary requests that see another app's hello reply just keep waiting for their own reply, as before.
  • Doesn't touch the other session: after a takeover the stream's UNSUBSCRIBE and RELEASE are skipped (they would land in the Suunto app's session) and done after the pause instead; without that, the restart is refused with 423.
  • Steps aside: emits DC_EVENT_WAITING (hosts can show "paused, the Suunto app is syncing"), then reads and discards packets until the link has been quiet for 15 s. That's longer than the Suunto app's ~10.6 s hello retry, so the app gets in and syncs while we wait. The pause lasts as long as the app's traffic keeps flowing, up to an hour, and is cancellable.
  • Resumes without a new hello, restarting the interrupted dive from the beginning (the stream can't resume partway). The Suunto app answers any hello on the link by re-opening its own session, which would end our stream again. Our session survives the other app's hello (the watch keeps answering our requests). A 423 after a pause means the app is still streaming its own dives, so it pauses again. Pauses don't use up retries; at most 5 per dive.
  • Before our own hello at open, listens 1.5 s and waits for quiet if another app is mid-sync. (Only logged: events aren't wired up until open returns.)

Testing

  • iPad + Nautic, DC Tester build 22 with this change, except the last part (the hour-long cap and the 423 "pause again", added afterwards; the cap was 5 min then): Suunto app open and synced first, then a full download of 12 entries while I force-quit and reopened the Suunto app three times. Each takeover was caught at once (240, 295 and 967 KB into a dive); DC Tester paused 59-96 s while the app connected and synced (113-213 of its packets), then resumed and restarted the dive. All 12 entries complete at their listed sizes. On the Suunto app's side the user sees a brief "not connected" on its first attempt, which it recovers from on its own retry within ~10 s.
  • iPhone + Ocean, Suunto app already connected (an earlier build 16 version of this change): DC Tester joined its link and the app's traffic interleaved; all entries exact, no pause needed.
  • Mac, simulated takeovers (a second process sending the same hello mid-download), on build 22 with an earlier revision of this change (before the fixes listed in Nautic/Ocean: let downloads coexist with the Suunto app on the same phone (pause and resume instead of truncating) libdc-swift#78): detected within ~0.2 s, paused, resumed without a hello; entries byte-identical to archived copies.
  • Compiles without warnings (-Wall -Wextra).

Logs, timelines and the four problems found along the way are in deepsealabs/libdc-swift#78.

Not covered yet

  • A Suunto app sync longer than the pause cap, and the 423 "pause again" path against a real long sync (implemented, untested).
  • A takeover during an ordinary request followed by a long app sync: if our reply is delayed past the 5 s timeout, that request fails.
  • Real-app takeovers have been tested on the Nautic only (the Ocean only with simulated ones), on iOS only, and through Device Explorer only; Auto download not yet.
  • A packet torn by radio noise now costs a 15 s pause where it used to be retried at once.

Happy to restructure or split further if you'd prefer.

…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).
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.
… 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.
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.
@trustthegoose

Copy link
Copy Markdown
Author

Two commits added after more testing, plus results with Auto download.

What this is built for. The main case is the Suunto app running in the background on the same phone: its hourly sync (weather and settings, no logbook access), the values it gets pushed, and the occasional reconnect, which sends a fresh hello. #15 and this PR cover all of those. If the user deliberately opens the Suunto app and it starts downloading dives, DC Tester should stand aside until it's done and then carry on; downloading from both apps at once is never a good idea, and the first new commit is what stops it.

1. The shared /Data stream handle (cf5be2e). The watch has one handle for streaming a dive's /Data, shared by every client on the link. I found it with a second client on the Mac that takes over with a hello and then streams three dives back to back. Another client's GET of a dive's /Data re-points that handle, so our subscription then carries the other client's dive, and its UNSUBSCRIBE ends our stream. In that first run, one dive came out truncated with the other client's data in it, caught only by the listed-size check. The commit treats another client's GET or SUBSCRIBE reply on our stream's handle (ignoring the slot byte) as a takeover, and after a pause, a size mismatch means the same thing: pause again and redo the dive instead of keeping a truncated one.

2. Waiting out a deliberate download (95cf871). A pause caused by another app downloading dives (it re-pointed our handle) no longer counts toward the 5-pause cap, so an app fetching many dives is waited out in full. The cap still applies to everything else, e.g. an app stuck reconnecting.

Results:

  • Second-client test on the Mac (hello, then three dives of ~1.2-1.3 MB back to back, then holding its last stream open for 25 s), with both commits: all 5 dives at their listed sizes, the 3 older ones byte-identical to archived copies. The hello caused pause 1/5; the two re-points for its 2nd and 3rd dives were each caught at once and not counted. Resuming during the 25 s hold gave a short dive (75,894 + 2,338 of 142,733 bytes), which the size check caught: pause 2/5, then a clean redo. About 11 minutes of the other client's traffic used 2 of the 5 pauses. Before the first commit, the same test kept a truncated dive with the other client's data in it.
  • iPad + Nautic, Auto download in the background, 12 dives:
    • Opening the Suunto app during dive 2 (a new session): DC Tester paused while the app synced (one quick "not connected" on the app, then a normal sync ~7 s later), and resumed on its own about 3 minutes later.
    • Switching to the already-running Suunto app (its foreground sync), and its Sync now button: no new hello; the app's traffic (a dive-list fetch each time) interleaved with the download, no pause needed.
    • All 12 dives complete at their listed sizes.

One UI note to add to the Explorer ones on deepsealabs/libdc-swift#78: the driver's pause and resume only show in the debug console, not in Auto download's live log, which is where a user would look.

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.
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.
… 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.
@trustthegoose

Copy link
Copy Markdown
Author

Three more commits, from a real-case test with the Suunto app. One of them touches shared code (hdlc.c), so please check that one in particular.

What went wrong. Phone + Ocean and iPad + Nautic, DC Tester Auto download on, Suunto app in the background on each, a bucket dive on both watches. DC Tester got both dives, but both Suunto apps stopped working: each kept sending one request about every 10 s, the watch answered every one (we can see the replies on the shared link), and the app never followed up. It reported the sync done without the new dive, and stayed that way until it was force-quit and reopened. Both times it started right after a hello from DC Tester that the app didn't answer with a hello of its own; DC Tester hellos that the app did answer within a second left it fine. In both bad cases the app was in the middle of its sync, in a quiet gap between requests.

Versions: Suunto app 3.15.0 (build 23972) on iOS 27.0.1, both devices (iPhone 16 Pro, iPad Pro M5); Ocean firmware 2.51.28, Nautic firmware 2.55.46; DC Tester build 22 with #15 and this PR. The Suunto app's side of this depends on its version, so the behaviour may change with app updates.

The commits:

  1. 37ea432 Don't say hello over another app's session. Before the hello, the driver sends 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 the driver says hello as before. If it's answered, the driver uses the existing session and skips the hello. The watch doesn't answer a client without its own session while another client's stream is running, so if other traffic arrives while waiting, the driver waits for quiet and asks again. On a fresh link this costs 1.5 s (logged, so the unanswered GET isn't mistaken for a fault).
  2. f98db6a hdlc: skip empty frames instead of losing sync (shared with suunto_eonsteel.c and divesoft_freedom.c). Back-to-back frames don't share a flag: one frame's closing 7E is followed by the next one's opening 7E. 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 starts each later read inside a frame again. It stays out of sync for as long as frames keep coming back to back, so it returns nothing but empty frames. On the Nautic that happened whenever the driver joined while another client was streaming: it heard nothing for the whole stream, including any reply meant for it. The fix treats two flags in a row as the start of the next frame, which is how HDLC readers usually behave; for the other drivers it only means an empty frame is skipped. Happy to move this into its own PR if you'd prefer.
  3. e38d304 Any other client's reply on our stream handle is a takeover, not only GET and SUBSCRIBE. In the Ocean run, the app's UNSUBSCRIBE (status 200) and FETCH on the shared /Data handle 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.

Results:

  • Mac, second client already streaming when the driver connects: it waits for quiet, the GET is answered, no hello, dive complete.
  • Mac, nobody else on the link: the GET goes unanswered, hello as before, dive complete.
  • Mac, the takeover test from the earlier comment (second client says hello, then streams three dives, then holds its last stream open): 6 dives at their listed sizes, the 3 with archived copies byte-identical. The second client's UNSUBSCRIBE during its hold is now caught at once instead of costing a counted pause and a redo; 1 of 5 pauses used.
  • Phone + Ocean and iPad + Nautic again, same setup, another bucket dive: DC Tester used the Suunto app's session on both (no hello), the apps synced at normal speed, and both apps got their new dive without a restart. DC Tester got both too.

One thing left: on the phone the Suunto app asked for the new dive while DC Tester was streaming it, got no reply, showed "last sync failed", and retried on its own about 3.5 minutes later, successfully. No data lost, but a user sees a failure message. Avoiding it would mean DC Tester holding back whenever it sees the app working; I've left that for later.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant