<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>snehan kekre · posts</title><description>Writing by Snehan Kekre: docs, devrel, open source, and diving.</description><link>https://snehankekre.com/</link><item><title>In macOS Seatbelt, bind and listen are two different permissions</title><link>https://snehankekre.com/posts/seatbelt-loopback/</link><guid isPermaLink="true">https://snehankekre.com/posts/seatbelt-loopback/</guid><description>network-bind authorizes bind(). network-inbound authorizes listen() and accept(). One propagates to the other under a condition that took me three days to see, because my probe called both syscalls inside one try block and labelled every failure with the name of the wrong one. The kernel had been logging the name of the operation it refused the entire time, one layer below the exception I was reading.</description><pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Seatbelt is the sandbox macOS puts a command inside when you run it under
&lt;code&gt;sandbox-exec&lt;/code&gt;. It has two rules that look like they cover the same ground.
&lt;code&gt;network-bind&lt;/code&gt; authorizes &lt;code&gt;bind()&lt;/code&gt;. &lt;code&gt;network-inbound&lt;/code&gt; authorizes &lt;code&gt;listen()&lt;/code&gt; and
&lt;code&gt;accept()&lt;/code&gt;. They are
separate operations that fail at different syscalls, and under one condition the second
silently stands in for the first.&lt;/p&gt;
&lt;p&gt;I got this wrong twice in three days. First I decided &lt;code&gt;network-bind&lt;/code&gt; was ignoring its
own address filter and refusing the bind, and I &lt;a href=&quot;https://github.com/snehankekre/quickstarted/blob/132d467191f812884b65c8cc7a2cc54e40b3f44f/src/quickstarted/exec/seatbelt.py#L65-L69&quot;&gt;wrote that into a source
comment&lt;/a&gt;.
Then I wrote a test, and the test convinced me &lt;code&gt;network-bind&lt;/code&gt; authorized nothing at all
and &lt;code&gt;network-inbound&lt;/code&gt; was doing the work.&lt;/p&gt;
&lt;p&gt;Both readings were wrong, and the second one had a table behind it, which is why I
believed it for as long as I did. My probe called &lt;code&gt;bind()&lt;/code&gt; and &lt;code&gt;listen()&lt;/code&gt; inside one
&lt;code&gt;try&lt;/code&gt; block and printed “bind FAILED” whichever of them the kernel refused, so every
row in that table named the wrong syscall. Everything below is the re-test with the two
calls separated.&lt;/p&gt;
&lt;h2 id=&quot;what-i-needed-the-sandbox-to-allow&quot;&gt;What I needed the sandbox to allow&lt;/h2&gt;
&lt;p&gt;I maintain &lt;a href=&quot;https://github.com/snehankekre/quickstarted&quot;&gt;a harness&lt;/a&gt; that runs documented
quickstart commands inside a sandbox and records which documentation pages an agent
reads. Quickstarts frequently end at “start the dev server and open it”, so the sandbox
has to let a process listen on loopback and poll itself, while still refusing to reach
any documentation host directly.&lt;/p&gt;
&lt;p&gt;On macOS the enforced backend is
&lt;a href=&quot;https://keith.github.io/xcode-man-pages/sandbox-exec.1.html&quot;&gt;&lt;code&gt;sandbox-exec&lt;/code&gt;&lt;/a&gt;, which
runs a command inside a policy you hand it as a file. Apple’s manual calls the machinery
underneath it &lt;a href=&quot;https://keith.github.io/xcode-man-pages/sandbox.7.html&quot;&gt;the sandbox
facility&lt;/a&gt;, and Seatbelt is the
name it goes by everywhere else. The tool is marked DEPRECATED, in a man page last
revised in 2017, and developers are pointed at &lt;a href=&quot;https://developer.apple.com/documentation/security/app-sandbox&quot;&gt;App
Sandbox&lt;/a&gt; instead. App
Sandbox covers apps you ship. I need to wrap whatever command a quickstart tells a
reader to run. Neither man page documents the profile language, so every operation name
below comes from Apple’s own profiles under &lt;code&gt;/System/Library/Sandbox/Profiles/&lt;/code&gt; and from
testing.&lt;/p&gt;
&lt;p&gt;Every task that started a server failed. The same tasks passed under the Docker backend,
so the tasks themselves were fine. The failure looked like this:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;PermissionError: [Errno 1] Operation not permitted&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;My profile had one rule with “bind” in the name, so I widened its filter from
&lt;code&gt;localhost:*&lt;/code&gt; to &lt;code&gt;*:*&lt;/code&gt;, added inbound and outbound rules for loopback, and shipped all
three edits in &lt;a href=&quot;https://github.com/snehankekre/quickstarted/commit/c58d43a3d280c93e5cf7f30a3f0f5f4cc76a4b3c&quot;&gt;one
commit&lt;/a&gt;.
The tasks passed. That left me three changes and a single pass result, which cannot
tell you which of the three did the work. I credited the one I had an explanation for,
and the explanation went into the comment above those lines in the same commit.&lt;/p&gt;
&lt;h2 id=&quot;the-test-that-agreed-with-me&quot;&gt;The test that agreed with me&lt;/h2&gt;
&lt;p&gt;Later I went back to do it properly. One base profile with no network rules, then
append rules one combination at a time and try to bind. The probe was two calls in a
row:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;s &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; socket.socket(socket.&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;AF_INET&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, socket.&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;SOCK_STREAM&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;try&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  s.bind((addr, port))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  s.listen(&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  print&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;bind ok&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;except&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; Exception&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; as&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; e:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  print&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;bind FAILED -&gt; &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;%r&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; %&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (e,))&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That produced a clean table. Here are its three rows, re-run today against the same
profiles:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;bind only        bind FAILED -&gt; PermissionError(1, &apos;Operation not permitted&apos;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;bind + inbound   bind ok&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;inbound only     bind ok&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Row one carries a &lt;code&gt;network-bind&lt;/code&gt; rule and fails. Row three has no &lt;code&gt;network-bind&lt;/code&gt; rule
anywhere in the profile and passes. The conclusion wrote itself: &lt;code&gt;network-bind&lt;/code&gt; does not
authorize a bind, &lt;code&gt;network-inbound&lt;/code&gt; does.&lt;/p&gt;
&lt;p&gt;I had the post drafted around that conclusion, and nothing I was going to do next would
have dislodged it. Every test I could think of running was a test I had designed while
believing it. So before publishing I went back at the draft with the opposite
instruction. Assume every claim in here is wrong, and go find the reason. The headline
claim lasted twenty minutes.&lt;/p&gt;
&lt;p&gt;Read the probe again. Nothing in it can tell a refused &lt;code&gt;bind()&lt;/code&gt; from a refused
&lt;code&gt;listen()&lt;/code&gt;. Those three rows are equally consistent with the answer I reached and with
the answer that turned out to be true, so the table could not have come out any other
way and it settled nothing. What I needed was a run where the two readings predict
different output, and I had no such run until I pulled the two calls apart.&lt;/p&gt;
&lt;h2 id=&quot;which-rule-authorizes-which-syscall&quot;&gt;Which rule authorizes which syscall&lt;/h2&gt;
&lt;p&gt;Separate the two calls and the picture inverts:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;$ sandbox-exec -f bindwild.sb python3 split.py 127.0.0.1 56412&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  BIND_OK  LISTEN_FAIL PermissionError(1, &apos;Operation not permitted&apos;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;bind()&lt;/code&gt; succeeded. &lt;code&gt;listen()&lt;/code&gt; is what the kernel refused. Every “denied” cell in my
table that had a &lt;code&gt;network-bind&lt;/code&gt; rule in it was a successful bind followed by a denied
listen.&lt;/p&gt;
&lt;p&gt;The rules map to syscalls the way their names suggest:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;network-bind&lt;/code&gt; authorizes &lt;code&gt;bind()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;network-inbound&lt;/code&gt; authorizes &lt;code&gt;listen()&lt;/code&gt; and &lt;code&gt;accept()&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;/posts/seatbelt-loopback/rule-syscall-map.svg&quot; alt=&quot;Two Seatbelt operations above the three syscalls they gate. network-bind sits above bind(); network-inbound spans listen() and accept(). A dashed arrow runs from network-inbound back to network-bind, labelled as propagating down only when the profile has no network-bind rule. A red bracket underneath spans bind() and listen(), marking the span my first probe collapsed into a single result labelled &amp;#x22;bind FAILED&amp;#x22;.&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Three syscalls, two operations, and one bracket covering the pair my probe could not
tell apart. Everything I concluded from that table came from reading a &lt;code&gt;listen()&lt;/code&gt; denial
as a &lt;code&gt;bind()&lt;/code&gt; denial.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The reason a profile with no &lt;code&gt;network-bind&lt;/code&gt; rule can still bind is that &lt;code&gt;network-bind&lt;/code&gt;
sits underneath &lt;code&gt;network-inbound&lt;/code&gt; in Seatbelt’s operation hierarchy. A rule written on
&lt;code&gt;network-inbound&lt;/code&gt; propagates down to &lt;code&gt;network-bind&lt;/code&gt; when the profile has no
&lt;code&gt;network-bind&lt;/code&gt; rule of its own. That inheritance produced row three of my table, the
inbound-only profile that printed &lt;code&gt;bind ok&lt;/code&gt; with no bind rule written anywhere in it. I
read that row as proof that &lt;code&gt;network-bind&lt;/code&gt; was doing nothing. It was the inbound rule
standing in for a bind rule that was not there.&lt;/p&gt;
&lt;p&gt;Precedence is by specificity rather than by position, which is worth knowing separately
because Seatbelt is usually described as last-rule-wins:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;(deny network-bind)(allow network-inbound)   -&gt; BIND_FAIL&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;(allow network-inbound)(deny network-bind)   -&gt; BIND_FAIL&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same outcome either way. The rule naming the more specific operation wins no matter
where it sits.&lt;/p&gt;
&lt;p&gt;Apple’s own profiles use the split exactly this way. From
&lt;code&gt;/System/Library/Sandbox/Profiles/com.apple.rpcbind.sb&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;;; Allow binding on our ticotsord transport&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;(allow network-bind (literal &quot;/private/var/run/rpcb.ticotsord&quot;))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;;; Allow receiving on our ticotsord transport&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;(allow network-inbound (literal &quot;/var/run/rpcb.ticotsord&quot;) (local tcp) (remote tcp))&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One socket, two rules. The bind rule names the transport and nothing else. The inbound
rule covers that same socket and adds &lt;code&gt;(local tcp) (remote tcp)&lt;/code&gt;, because receiving is a
separate authorization that takes its own filters. If one operation covered both
syscalls, the second rule would be redundant, and Apple would not have written it.&lt;/p&gt;
&lt;h2 id=&quot;the-kernel-had-been-logging-the-answer&quot;&gt;The kernel had been logging the answer&lt;/h2&gt;
&lt;p&gt;Both denials arrive in Python as the same object. &lt;code&gt;PermissionError(1, &apos;Operation not permitted&apos;)&lt;/code&gt; is what you get for a refused &lt;code&gt;bind()&lt;/code&gt; and for a refused &lt;code&gt;listen()&lt;/code&gt;, and
the exception carries nothing else to tell them apart. That is the layer I was reading,
and it is the wrong layer. One level down, the kernel writes the name of the operation
it refused to the unified log, every time.&lt;/p&gt;
&lt;p&gt;Two profiles, the first allowing &lt;code&gt;network-bind&lt;/code&gt; alone and the second allowing
&lt;code&gt;network-inbound&lt;/code&gt; while denying &lt;code&gt;network-bind&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;$ sandbox-exec -f bindonly.sb python3 split.py 127.0.0.1 57111&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;BIND_OK  LISTEN_FAIL PermissionError(1, &apos;Operation not permitted&apos;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;$ sandbox-exec -f denybind.sb python3 split.py 127.0.0.1 57112&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;BIND_FAIL           PermissionError(1, &apos;Operation not permitted&apos;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;$ log show --last 25s --predicate &apos;eventMessage CONTAINS &quot;deny(1) network&quot;&apos; --style compact&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;kernel (Sandbox) Sandbox: Python(80245) deny(1) network-inbound local:*:57111&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;kernel (Sandbox) Sandbox: Python(80246) deny(1) network-bind    local:*:57112&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Identical errors in Python, and the log separates them by operation name and prints the
filter it evaluated. It needs no privileges, no modification to the profile, and no
change to the program under test. Run against row one of my table, it would have printed
&lt;code&gt;network-inbound&lt;/code&gt; beside a probe whose own output said &lt;code&gt;bind FAILED&lt;/code&gt;, and three days
would have ended that afternoon.&lt;/p&gt;
&lt;p&gt;One thing to know before leaning on it. The kernel collapses identical denial messages,
so five refused &lt;code&gt;listen()&lt;/code&gt; calls on the same port from one process produce a single log
line, and a loop that fails on every iteration reads as though it failed once. Vary the
port if you want to count them.&lt;/p&gt;
&lt;p&gt;Under the pair of rules I ended up shipping, that same predicate stays silent through a
full bind, listen, accept, and poll on loopback, and prints exactly one line for a dial
to &lt;code&gt;1.1.1.1&lt;/code&gt;: &lt;code&gt;deny(1) network-outbound remote:*:80&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;the-two-rules-you-need&quot;&gt;The two rules you need&lt;/h2&gt;
&lt;p&gt;Two rules are enough to serve and poll on loopback:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;(allow network-inbound (local ip &quot;localhost:*&quot;))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;(allow network-outbound (remote ip &quot;localhost:*&quot;))&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The outbound rule is separately required. Without it the server comes up and nothing can
talk to it, including the process that started it:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;step 1 bind/listen ok on 127.0.0.1:50580&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;step 2 accept+poll FAILED -&gt; URLError(PermissionError(1, &apos;Operation not permitted&apos;))&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Remote egress stays denied under all of these, which I checked with a raw dial to a
literal IP rather than a hostname, since a hostname failure could be DNS and tells you
nothing about which rule refused:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;raw TCP 1.1.1.1:80 -&gt; refused: PermissionError(1, &apos;Operation not permitted&apos;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both rules lean on the &lt;code&gt;localhost&lt;/code&gt; token, which does not mean what it looks like.&lt;/p&gt;
&lt;h2 id=&quot;the-filter-is-stranger-than-it-looks&quot;&gt;The filter is stranger than it looks&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;(local ip &quot;localhost:*&quot;)&lt;/code&gt; is not an address match. The host component accepts two
tokens and the compiler rejects everything else:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;$ sandbox-exec -f bogus.sb python3 split.py 127.0.0.1 57999&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;sandbox-exec: host must be * or localhost in network address&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;localhost&lt;/code&gt; here means “an address belonging to this machine” rather than
&lt;code&gt;127.0.0.0/8&lt;/code&gt;. Under an inbound rule naming &lt;code&gt;localhost&lt;/code&gt;, a process can bind &lt;code&gt;0.0.0.0&lt;/code&gt;
and the machine’s LAN address, because those are addresses this host owns. The filter is
doing its job. For inbound it is simply vacuous, since every address you can bind
belongs to this host.&lt;/p&gt;
&lt;p&gt;The same token genuinely constrains outbound, where the distinction is observable:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;-&gt; 192.168.50.1:80    EPERM          (the router, a different host)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;-&gt; 192.168.50.205:9   ECONNREFUSED   (this host&apos;s LAN address, permitted)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;-&gt; 1.1.1.1:80         EPERM&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The port component does filter on both. Under &lt;code&gt;(allow network-inbound (local ip &quot;localhost:12345&quot;))&lt;/code&gt;, binding 12345 works and binding 12346 returns EPERM.&lt;/p&gt;
&lt;h2 id=&quot;a-demo-i-should-not-have-trusted-either&quot;&gt;A demo I should not have trusted either&lt;/h2&gt;
&lt;p&gt;Having found that a sandboxed process can bind &lt;code&gt;0.0.0.0&lt;/code&gt;, I curled its LAN address from
outside the sandbox, got a response, and was ready to write that a sandboxed command can
publish a service to everyone on your network.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;$ route get 192.168.50.205&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;   route to: mac&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  interface: lo0&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Traffic to my own LAN address never leaves the machine. My curl went over loopback, and
so did every other client I could produce locally. A wildcard listener with the
application firewall off is a real reason to expect LAN exposure, and &lt;code&gt;netstat&lt;/code&gt; shows
&lt;code&gt;tcp4 *.40030 LISTEN&lt;/code&gt;. I haven’t demonstrated it. Settling it needs a second machine,
which is a test I have not run.&lt;/p&gt;
&lt;h2 id=&quot;if-you-are-copying-these-rules&quot;&gt;If you are copying these rules&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;(allow network-bind (local ip &quot;*:*&quot;))&lt;/code&gt; line that looks inert is the rule
authorizing the bind. Deleting it is safe only when the profile has no other bind rule,
because inbound then propagates down and brings its own filter with it, including the
port constraint. In a profile carrying an explicit &lt;code&gt;(deny network-bind)&lt;/code&gt; or a narrower
inbound filter, removing that line breaks the server.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/snehankekre/quickstarted/blob/132d467191f812884b65c8cc7a2cc54e40b3f44f/src/quickstarted/exec/seatbelt.py#L65-L69&quot;&gt;The comment I wrote above those
lines&lt;/a&gt;
claims three things: that &lt;code&gt;network-bind&lt;/code&gt; does not match a &lt;code&gt;localhost:*&lt;/code&gt; filter, that it
silently refuses the bind, and that all three rules are needed. So I rebuilt the profile
as it stood before that commit and added the three edits back one at a time, against a
probe that binds, listens, accepts a connection, and polls itself:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;bind localhost:*                        BIND_OK  LISTEN_FAIL&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;bind localhost:* + inbound              BIND_OK  LISTEN_OK   POLL_FAIL&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;bind localhost:* + inbound + outbound   BIND_OK  LISTEN_OK   POLL_OK&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;bind *:*         + inbound + outbound   BIND_OK  LISTEN_OK   POLL_OK&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The filter I called broken matched the bind in every row, including the first, where no
other rule in the profile could have authorized it. The inbound rule and the outbound
rule each cleared a real failure. The last two rows are identical, so widening from
&lt;code&gt;localhost:*&lt;/code&gt; to &lt;code&gt;*:*&lt;/code&gt; bought me nothing, and the comment explaining why I had done it
was explaining a change that did nothing. Four runs, and I could have had them before I
wrote a word of it. The comment comes out.&lt;/p&gt;
&lt;h2 id=&quot;two-things-that-cost-me-time&quot;&gt;Two things that cost me time&lt;/h2&gt;
&lt;p&gt;Use a fresh port on every run. A port in &lt;code&gt;TIME_WAIT&lt;/code&gt; from a previous successful run
reports &lt;code&gt;Address already in use&lt;/code&gt;, and when you already suspect your policy that reads
exactly like a denial.&lt;/p&gt;
&lt;p&gt;Do not let two syscalls share a &lt;code&gt;try&lt;/code&gt; block when you are working out which one the
kernel refused. I had a table, the table had real numbers in it, and it was measuring
something other than what its column header said. Three days and a source comment came
out of that. Both of my wrong answers survived every test I thought to run, because I
designed those tests while believing the thing they were supposed to check. Neither
survived twenty minutes of trying to break them on purpose.&lt;/p&gt;
&lt;p&gt;All results are macOS 15.7.3, build 24G419, Darwin 24.6.0. None of this is documented or
promised, so pin your own results to a version and re-check after upgrades.&lt;/p&gt;</content:encoded></item><item><title>My dive computer records 17 channels. Subsurface exports 4.</title><link>https://snehankekre.com/posts/lossy-dive-formats/</link><guid isPermaLink="true">https://snehankekre.com/posts/lossy-dive-formats/</guid><description>Divers blame the interchange formats for losing decompression data. I counted the fields. UDDF has carried GF99 since 2018, DAN&apos;s format has carried the deco ceiling since 2006, and there is a version of UDDF that real software writes and that was never published.</description><pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;My Perdix 2 records seventeen channels per sample. The open formats can carry sixteen of
them. &lt;a href=&quot;https://subsurface-divelog.org/&quot;&gt;Subsurface&lt;/a&gt;, the best open-source dive log there is, exports four.&lt;/p&gt;
&lt;p&gt;Divers blame the formats for that gap. I did too, &lt;a href=&quot;https://github.com/snehankekre/bottomtime/blob/78b0b48057fcf56d8400a61afa84caa3aca012c9/README.md?plain=1#L9&quot;&gt;in my own
README&lt;/a&gt;.
The gap is in the exporters.&lt;/p&gt;
&lt;h2 id=&quot;one-dive-seventeen-channels&quot;&gt;One dive, seventeen channels&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;A823C228#13&lt;/code&gt;, 101.5 metres, 101 minutes, 616 samples, open-circuit trimix. Counting the
columns actually populated for that dive rather than what the schema permits:&lt;/p&gt;
&lt;p&gt;depth, temperature, GF99, deco ceiling, time to surface, next stop depth, next stop
time, CNS percent, battery percent, battery voltage, average ppO2, oxygen fraction,
helium fraction, an in-deco flag, a packed status byte, a solenoid fire count, and one
raw oxygen sensor reading.&lt;/p&gt;
&lt;p&gt;GF99 is the channel divers assume is trapped in vendor formats. It reads out how close
your leading tissue is to its Bühlmann limit, it lives in byte 25 of Shearwater’s sample
record, Shearwater has never documented it, and I spent a fortnight
&lt;a href=&quot;/posts/verifying-undocumented-bytes&quot;&gt;proving it was what I thought it was&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/posts/lossy-dive-formats/channel-matrix.svg&quot; alt=&quot;Matrix of 17 dive channels against four formats. UDDF 3.2.3 carries 12, DL7 2006 carries 7, the unpublished UDDF 3.3.0 carries 13, and Subsurface&amp;#x27;s UDDF exporter writes 4. Only the solenoid fire count is absent everywhere.&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Filled means the format has somewhere to put that channel. The three specifications
between them cover sixteen of the seventeen. The exporter column is the one your data
passes through.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;uddf-standardised-gf99-in-2018&quot;&gt;UDDF standardised GF99 in 2018&lt;/h2&gt;
&lt;p&gt;UDDF is the Universal Dive Data Format, an XML schema for dive logs. From
&lt;code&gt;uddf_3.2.3.xsd&lt;/code&gt;, inside &lt;code&gt;waypointType&lt;/code&gt;, the per-sample record:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;xml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;xs:element&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; name&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;gradientfactor&quot;&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; minOccurs&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;0&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  &amp;#x3C;&lt;/span&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;xs:complexType&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;xs:simpleContent&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    &amp;#x3C;&lt;/span&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;xs:extension&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; base&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;xs:float&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;      &amp;#x3C;&lt;/span&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;xs:attribute&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; name&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;tissue&quot;&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; type&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;xs:int&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;/&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    &amp;#x3C;/&lt;/span&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;xs:extension&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  &amp;#x3C;/&lt;/span&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;xs:simpleContent&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&gt;&amp;#x3C;/&lt;/span&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;xs:complexType&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&amp;#x3C;/&lt;/span&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;xs:element&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A per-sample gradient factor, with an attribute naming which of the sixteen compartments
is leading. My dive computer does not record that attribute. On this field the XML
standard beats the binary format I reverse engineered.&lt;/p&gt;
&lt;p&gt;The prose around it is ambiguous. It calls the value “80 % of the maximum tolerated
saturation”, which is not how Shearwater defines GF99, and the two quantities agree only
at zero ambient pressure. The usage settles it. Subsurface ships an APD Inspiration log
in its test data where &lt;code&gt;&amp;#x3C;gradientfactor&gt;&lt;/code&gt; appears 695 times across nineteen values,
climbing from 0.50 to 0.70, while that dive’s &lt;code&gt;&amp;#x3C;setgflow&gt;&lt;/code&gt; is 0.50 and its
&lt;code&gt;&amp;#x3C;setgfhigh&gt;&lt;/code&gt; is 0.70. A number that walks from the diver’s low setting to their high
setting over a dive is a live GF readout.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;&amp;#x3C;batterychargecondition&gt;&lt;/code&gt;, &lt;code&gt;&amp;#x3C;cns&gt;&lt;/code&gt;, &lt;code&gt;&amp;#x3C;otu&gt;&lt;/code&gt;, &lt;code&gt;&amp;#x3C;decostop&gt;&lt;/code&gt;, &lt;code&gt;&amp;#x3C;nodecotime&gt;&lt;/code&gt;, &lt;code&gt;&amp;#x3C;setpo2&gt;&lt;/code&gt;,
&lt;code&gt;&amp;#x3C;measuredpo2&gt;&lt;/code&gt;, &lt;code&gt;&amp;#x3C;heartrate&gt;&lt;/code&gt; and &lt;code&gt;&amp;#x3C;tankpressure&gt;&lt;/code&gt; are waypoint children too. Twelve of
my seventeen. &lt;code&gt;&amp;#x3C;decostop&gt;&lt;/code&gt; is the next stop, which is a different number from the
current ceiling. UDDF has an element for the first and none for the second.&lt;/p&gt;
&lt;h2 id=&quot;dl7-had-the-ceiling-in-2006&quot;&gt;DL7 had the ceiling in 2006&lt;/h2&gt;
&lt;p&gt;UDDF has no ceiling and no status byte. DL7 had both in 2006, twelve years earlier.&lt;/p&gt;
&lt;p&gt;DL7 is Divers Alert Network’s format, built for Project Dive Exploration. The
specification is a Word document by Petar J. Denoble dated July 2006, and it survives in
the docs directory of an &lt;a href=&quot;https://github.com/johnstonskj/PyDL7&quot;&gt;archived Python library&lt;/a&gt;.
Segment attribute tables, HL7-derived delimiters, coded value tables for computer
identifiers and gas codes.&lt;/p&gt;
&lt;p&gt;Its per-sample segment is ZDP. Two of its fields:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;7 | 6 | NM | O | Current Ceiling | Value&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;9 |   | ST | O | Warning number  | Warning number(s) in Hex as defined within the&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;                                   specific Unit. May contain more that one Warning&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;                                   Number separated by character ^&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A per-sample deco ceiling, and a per-sample hex warning word whose bits each computer
defines for itself. The second one is a packed status byte under another name. DAN
specified both eight years before my dive computer existed.&lt;/p&gt;
&lt;p&gt;UDDF kept growing after 2018. None of the growth is on its website.&lt;/p&gt;
&lt;h2 id=&quot;uddf-330-was-never-published-software-writes-it-anyway&quot;&gt;UDDF 3.3.0 was never published. Software writes it anyway.&lt;/h2&gt;
&lt;p&gt;UDDF 3.2.3, dated 15 November 2018, is the newest published release. A 3.3.0 exists
too, in files people are writing today.&lt;/p&gt;
&lt;p&gt;Subsurface’s test data contains &lt;code&gt;dives/test-apd-inspiration.uddf&lt;/code&gt;. It opens
&lt;code&gt;&amp;#x3C;uddf version=&quot;3.3.0&quot;&gt;&lt;/code&gt; and its waypoints carry this:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;xml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;ppo2&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; ref&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;o2sensor_c1_1&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&gt;35000.0&amp;#x3C;/&lt;/span&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;ppo2&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;batteryvoltage&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; ref&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;battery_c1_1&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&gt;5.6&amp;#x3C;/&lt;/span&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;batteryvoltage&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;batteryvoltage&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; ref&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;battery_c1_2&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&gt;5.7&amp;#x3C;/&lt;/span&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;batteryvoltage&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Battery voltage per waypoint, per device, so a rebreather with two controllers records
both. Elsewhere in the same file, &lt;code&gt;&amp;#x3C;timetosurface&gt;60.0&amp;#x3C;/timetosurface&gt;&lt;/code&gt;. Individual
oxygen cells get their own values instead of one averaged number.&lt;/p&gt;
&lt;p&gt;That revision was never released. The UDDF site lists 3.2.1 as current and serves a
certificate that expired in July 2025 for an unrelated hostname. The schema directory
stops at 3.2.2. Meanwhile commercial dive software writes 3.3.0 files and an
open-source dive log keeps one as a test fixture.&lt;/p&gt;
&lt;p&gt;Across all three specifications, sixteen of my seventeen channels have somewhere to go.
The seventeenth is the count of times a rebreather’s solenoid fired to inject oxygen,
and nothing has a field for it.&lt;/p&gt;
&lt;p&gt;The standard’s most recent useful version is a private agreement between vendors. You
find it by reading files.&lt;/p&gt;
&lt;h2 id=&quot;subsurface-reads-more-uddf-than-it-writes&quot;&gt;Subsurface reads more UDDF than it writes&lt;/h2&gt;
&lt;p&gt;Subsurface’s internal sample structure, &lt;code&gt;core/sample.h&lt;/code&gt;, holds time, stoptime, ndl, tts,
rbt, depth, stopdepth, temperature, ten cylinder pressures, setpoint, six oxygen sensor
readings plus one computer-reported ppO2, bearing, cns, heartbeat, sac, and an in-deco
flag. That is enough for most of my dive.&lt;/p&gt;
&lt;p&gt;Its UDDF exporter is &lt;code&gt;xslt/uddf-export.xslt&lt;/code&gt;, 773 lines with two waypoint-emitting
branches. Between them they write seven elements: &lt;code&gt;depth&lt;/code&gt;, &lt;code&gt;divetime&lt;/code&gt;, &lt;code&gt;temperature&lt;/code&gt;,
&lt;code&gt;tankpressure&lt;/code&gt;, &lt;code&gt;switchmix&lt;/code&gt;, &lt;code&gt;alarm&lt;/code&gt;, &lt;code&gt;heading&lt;/code&gt;. The file contains no &lt;code&gt;xsl:element&lt;/code&gt; and
no dynamic construction, so that list is complete.&lt;/p&gt;
&lt;p&gt;Subsurface holds ndl, tts, cns, setpoint, oxygen sensors and heart rate in memory. UDDF
has elements for all of them. The exporter writes none.&lt;/p&gt;
&lt;p&gt;Import is a different story. It runs through a second stylesheet,
&lt;code&gt;xslt/uddf.xslt&lt;/code&gt;, 1050 lines, which handles &lt;code&gt;alarm&lt;/code&gt;, &lt;code&gt;heading&lt;/code&gt;, &lt;code&gt;cns&lt;/code&gt;, &lt;code&gt;otu&lt;/code&gt;,
&lt;code&gt;batteryvoltage&lt;/code&gt;, &lt;code&gt;setgflow&lt;/code&gt;, &lt;code&gt;setgfhigh&lt;/code&gt;, &lt;code&gt;scrubber&lt;/code&gt; and &lt;code&gt;divemode&lt;/code&gt;. It will ingest the
per-cell &lt;code&gt;&amp;#x3C;ppo2&gt;&lt;/code&gt; values and battery voltages from a 3.3.0 file that no published schema
describes.&lt;/p&gt;
&lt;p&gt;So one project ships a reader that understands the rich half of UDDF and a writer that
emits seven elements of it. Whatever a Subsurface user exports, the next program reads a
thinner dive than Subsurface could have described.&lt;/p&gt;
&lt;p&gt;For my 101 metre dive: seventeen recorded, sixteen representable, four written. Depth,
time, temperature, gas.&lt;/p&gt;
&lt;p&gt;None of this is a complaint about Subsurface, which is free, excellent, and the reason
most divers have their data at all. That XSLT is old, XSLT is a miserable language to
extend, and UDDF export matters far less to its users than supporting the next computer
over USB. Jef Driesen, who maintains libdivecomputer, is thanked in UDDF’s own author
list. These people have done far more for open dive data than I have. The data is
thinner at the end of the chain anyway, and UDDF’s authors saw that coming.&lt;/p&gt;
&lt;h2 id=&quot;uddf-has-a-slot-for-the-raw-bytes-nobody-fills-it&quot;&gt;UDDF has a slot for the raw bytes. Nobody fills it.&lt;/h2&gt;
&lt;p&gt;UDDF has an element called &lt;code&gt;&amp;#x3C;dcdump&gt;&lt;/code&gt;: the dive computer’s raw memory, bzip2-compressed
and base64-encoded. The rationale:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Such file can be useful as dive computer data backup; of course the backup usually
cannot be restored to original device but UDDF software can access it later, i.e. to
perform another, more complete conversion into UDDF format&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Somebody in 2018 had watched this happen often enough to add a place for the original
bytes, so a better decoder could revisit them. It sits under &lt;code&gt;&amp;#x3C;divecomputercontrol&gt;&lt;/code&gt;,
beside the log instead of inside it. I have never seen a tool emit one.&lt;/p&gt;
&lt;p&gt;That is the design of my own store, which keeps original bytes content-addressed and
treats the decoded database as a rebuildable view. I built it because I thought the
formats had failed me. The formats had done their part. Every program that touches them
implements the subset its author needed that week, that subset becomes the format in
practice, and you learn which subset it was years later, when you finally have a
question.&lt;/p&gt;
&lt;h2 id=&quot;the-same-thing-happens-outside-diving&quot;&gt;The same thing happens outside diving&lt;/h2&gt;
&lt;p&gt;Dive logs are small enough that you can count every field and read every exporter in an
afternoon. That is the only unusual thing about this case.&lt;/p&gt;
&lt;p&gt;RFC 5545 gives calendars six component types and three alarm actions. Google’s CalDAV
API supports four of the six and two of the three, and Google’s own developer
documentation is where you find that out: “Doesn’t support &lt;code&gt;VTODO&lt;/code&gt; or &lt;code&gt;VJOURNAL&lt;/code&gt; data”,
“Doesn’t support the &lt;code&gt;AUDIO&lt;/code&gt; action”. Any format where a committee wrote a large
specification and a few libraries implemented the parts their authors needed works this
way, healthcare records and geospatial data included.&lt;/p&gt;
&lt;p&gt;So stop grading a format by its specification. Grade it by what the tools you actually
use will read and write. That intersection is smaller than the spec, smaller than any
single tool’s internal model, and you can measure it in an afternoon. Then keep the
original bytes, because the intersection shrinks to whatever two programs happened to
agree on, and it is never the part you will want.&lt;/p&gt;
&lt;h2 id=&quot;sources&quot;&gt;Sources&lt;/h2&gt;
&lt;p&gt;UDDF 3.2.3, the newest published release:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.streit.cc/resources/UDDF/v3.2.3/en/index.html&quot;&gt;The documentation index&lt;/a&gt;,
dated 15 November 2018&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.streit.cc/resources/UDDF/v3.2.3/schema/uddf_3.2.3.xsd&quot;&gt;&lt;code&gt;uddf_3.2.3.xsd&lt;/code&gt;&lt;/a&gt;,
the schema itself, which the documentation’s own schema chapter does not link&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.streit.cc/resources/UDDF/v3.2.3/en/waypoint.html&quot;&gt;&lt;code&gt;&amp;#x3C;waypoint&gt;&lt;/code&gt;&lt;/a&gt;,
&lt;a href=&quot;https://www.streit.cc/resources/UDDF/v3.2.3/en/gradientfactor.html&quot;&gt;&lt;code&gt;&amp;#x3C;gradientfactor&gt;&lt;/code&gt;&lt;/a&gt;
and &lt;a href=&quot;https://www.streit.cc/resources/UDDF/v3.2.3/en/dcdump.html&quot;&gt;&lt;code&gt;&amp;#x3C;dcdump&gt;&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;DL7:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/johnstonskj/PyDL7/blob/master/docs/reference/dl7-specification.doc&quot;&gt;DL7 Standard&lt;/a&gt;,
Divers Alert Network, Petar J. Denoble, July 2006. A Word file in the archived PyDL7
repository, which is the only copy I could find.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Subsurface, read at master:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/subsurface/subsurface/blob/master/core/sample.h&quot;&gt;&lt;code&gt;core/sample.h&lt;/code&gt;&lt;/a&gt;,
what it holds per sample&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/subsurface/subsurface/blob/master/xslt/uddf-export.xslt&quot;&gt;&lt;code&gt;xslt/uddf-export.xslt&lt;/code&gt;&lt;/a&gt;,
what it writes&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/subsurface/subsurface/blob/master/xslt/uddf.xslt&quot;&gt;&lt;code&gt;xslt/uddf.xslt&lt;/code&gt;&lt;/a&gt;,
what it reads&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/subsurface/subsurface/blob/master/dives/test-apd-inspiration.uddf&quot;&gt;&lt;code&gt;dives/test-apd-inspiration.uddf&lt;/code&gt;&lt;/a&gt;,
the UDDF 3.3.0 file&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Calendars:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc5545.txt&quot;&gt;RFC 5545&lt;/a&gt;, the component types in
sections 3.6.1 to 3.6.6 and the alarm actions in section 3.8.6.1&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.google.com/workspace/calendar/caldav/v2/guide&quot;&gt;Google’s CalDAV API developer guide&lt;/a&gt;,
under Specifications, where the unsupported list lives&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Channel counts come from my own store, one query per column across the 616 samples of
&lt;code&gt;A823C228#13&lt;/code&gt;. The decoder is &lt;a href=&quot;https://github.com/snehankekre/bottomtime&quot;&gt;bottomtime&lt;/a&gt;
and the format work behind it is &lt;a href=&quot;https://github.com/snehankekre/pnf&quot;&gt;pnf&lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>Two wrists, one dive</title><link>https://snehankekre.com/posts/matching-two-dive-computers/</link><guid isPermaLink="true">https://snehankekre.com/posts/matching-two-dive-computers/</guid><description>My Garmin and my Shearwater both logged my 101.5 m dive, 19 seconds apart on clocks that don&apos;t agree about what time is. How bottomtime decides two logs are the same dive so you can do it yourself.</description><pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Technical divers wear two computers for redundancy. If one dies 75 m inside a cave or
at 90 m on a wreck, and the other can get you out. Mine are
a Garmin Descent Mk3i on the left wrist and a Shearwater Perdix 2 on the right, and they
both log everything. Which means every dive I do produces two files that describe the
same hour underwater and agree on almost nothing administrative. Neither the start time nor the
duration, and sometimes not even how many dives it was.
&lt;a href=&quot;https://github.com/snehankekre/bottomtime&quot;&gt;bottomtime&lt;/a&gt;’s job is to figure out which
logs are the same physical dive without ever merging them. This post is about how.&lt;/p&gt;
&lt;h2 id=&quot;nobody-agrees-what-time-it-is&quot;&gt;Nobody agrees what time it is&lt;/h2&gt;
&lt;p&gt;Take my deepest dive, #202, 101.5 m at the Drop Off dive site in Tulamben. The Perdix says it started at
&lt;code&gt;2025-09-27 11:55:43&lt;/code&gt;. The Garmin says &lt;code&gt;2025-09-27 03:56:02&lt;/code&gt;. Both are right.&lt;/p&gt;
&lt;p&gt;The Perdix logs its epochs on a wall clock with no timezone attached. That 11:55:43 is
Bali time wearing a UTC costume. The Garmin records true UTC and, separately, the local
offset (+480 minutes). Subtract eight hours from the Perdix’s number and you get
03:55:43 UTC, 19 seconds before the Garmin’s start. Nineteen seconds is me pressing two
buttons on two wrists while I do my surface swim to the entry point.&lt;/p&gt;
&lt;p&gt;But “subtract eight hours” was the answer, and the matcher’s first problem is that the
Shearwater log never states the question. So it borrows the answer from the other wrist. For each Shearwater dive, try the UTC offsets observed on Garmin dives nearby in
calendar time (all 84 of my matched pairs so far are +480, but the code doesn’t assume
that. When I dive somewhere else, the Garmin will have logged the new offset). Under
each candidate offset, look for interval overlap of at least half the shorter log,
with a sanity check that the shorter log isn’t somehow deeper than the longer one.&lt;/p&gt;
&lt;p&gt;Passing the overlap check only makes a pair a candidate. Acceptance rides on the depth
profiles.&lt;/p&gt;
&lt;h2 id=&quot;the-depth-profile-is-the-fingerprint&quot;&gt;The depth profile is the fingerprint&lt;/h2&gt;
&lt;p&gt;Two computers strapped to the same body sample the same physical curve, and depth over
time is the only channel they share. (GPS is no help. Seawater absorbs the signal
within a metre of the surface, so the Garmin has a fix for the entry point and nothing
after it.) The matcher
takes both depth series, lays them on an in-memory 10-second grid, and slides one
against the other across lags of ±300 s, scoring each shift with normalized
cross-correlation. The stored samples are never resampled. The grid exists only long
enough to compute the score.&lt;/p&gt;
&lt;p&gt;Here are the two real series from dive #202. Drag the lag and watch the score respond:&lt;/p&gt;
&lt;div id=&quot;lag-widget&quot; hidden style=&quot;border: 1px solid color-mix(in srgb, var(--ink) 35%, transparent); padding: 1rem; margin: 1.5rem 0; font-family: var(--font-mono, monospace); font-size: 0.8rem;&quot;&gt;
  &lt;canvas id=&quot;lag-profiles&quot;&gt;&lt;/canvas&gt;
  &lt;canvas id=&quot;lag-ncc&quot; style=&quot;margin-top: 0.5rem;&quot;&gt;&lt;/canvas&gt;
  &lt;div style=&quot;display: flex; align-items: center; gap: 0.75rem; margin-top: 0.75rem; flex-wrap: wrap;&quot;&gt;
    &lt;label for=&quot;lag-slider&quot;&gt;lag&lt;/label&gt;
    &lt;input id=&quot;lag-slider&quot; type=&quot;range&quot; min=&quot;-120&quot; max=&quot;120&quot; step=&quot;5&quot; value=&quot;-120&quot; style=&quot;flex: 1; min-width: 10rem; accent-color: var(--accent);&quot;&gt;
    &lt;span id=&quot;lag-value&quot; style=&quot;min-width: 4ch; text-align: right;&quot;&gt;0 s&lt;/span&gt;
    &lt;span&gt;NCC &lt;b id=&quot;ncc-value&quot;&gt;?&lt;/b&gt;&lt;/span&gt;
  &lt;/div&gt;
&lt;/div&gt;
&lt;script src=&quot;/posts/matching-two-dive-computers/widget.js&quot; defer data-astro-rerun=&quot;&quot;&gt;&lt;/script&gt;
&lt;p&gt;(The widget runs the same resample-and-correlate code as the matcher, ported to ~40
lines of JS, on the actual logs downsampled to the 10 s grid. If it’s not rendering,
the data file didn’t load, and hiding beats faking.)&lt;/p&gt;
&lt;p&gt;Two things show up while you drag. The score peaks at +20 s. After the
whole-hours offset is removed, the residual disagreement between my two wrists is
20 seconds, and the matcher stores that number (&lt;code&gt;residual_skew_s&lt;/code&gt;) rather than
correcting either series. And the peak is gentle. Even 120 seconds off, this dive still
scores above 0.95 because a deco profile is mostly slow monotonic segments. That
gentleness is why the acceptance threshold is a greedy 0.95 with an ambiguity rule. If
two candidate Garmin dives score within 0.02 of each other, the matcher refuses to
choose and flags the dive for human review instead.&lt;/p&gt;
&lt;p&gt;On the full-resolution series the winning score for this pair is 0.99976, with a
duration disagreement of 43 seconds and a max-depth disagreement of 0.37 m.&lt;/p&gt;
&lt;h2 id=&quot;then-i-took-it-to-a-pool&quot;&gt;Then I took it to a pool&lt;/h2&gt;
&lt;p&gt;Cross-correlation has a failure mode. It measures the agreement of &lt;em&gt;variation&lt;/em&gt;, and a
pool session has almost none. Go 3 m down, stay there, come up. Normalize a flat
line and you get noise.&lt;/p&gt;
&lt;p&gt;My worst real case: one afternoon of skills practice on 2025-12-17. The Garmin logged it
as a single 113-minute activity, max depth 8.2 m, because the Descent keeps recording
through surface pauses. The Perdix, which cuts a log whenever I surface for more than a
moment, cut eight logs over the same afternoon, two of them 46-second and 116-second
blips too brief to pair with anything. The six that could pair ran from 345 s to
1,382 s. The two deeper drills matched on profile shape (0.991 and 0.975). The four
shallow ones scored as low as 0.559, hopeless as correlations, while being blatantly
the same water on the same day.&lt;/p&gt;
&lt;p&gt;So there’s a second acceptance path for this shape of failure. If the interval
overlap is near-total (at least 0.85) and the median absolute depth difference at the
best lag is 0.75 m or less, accept. Two instruments on one body must agree on depth
even when the profile is too boring to correlate. Thirteen of my 84 matches came in
through that door, all of them pool sessions and shallow drills.&lt;/p&gt;
&lt;p&gt;None of these thresholds is principled. They’re placed in the gaps of the observed
distributions. Profile-accepted pairs score between 0.962 and 0.99997 against the 0.95
bar, and the pool path’s observed depth deltas run 0.07 to 0.20 m against the 0.75 m
ceiling, so each rule has room on both sides. Duration is not used as evidence at all.
The deltas run from 1 second to 6,463 seconds (median 48 s, but 26 of the 84 pairs
disagree by more than five minutes), all of it the Descent’s surface-pause habit, none
of it saying anything about identity.&lt;/p&gt;
&lt;h2 id=&quot;a-match-is-just-a-row&quot;&gt;A match is just a row&lt;/h2&gt;
&lt;p&gt;When a pair is accepted, nothing is merged. The match is a row: which Garmin log, which
Shearwater log, the clock offset (+28,800 s), the residual skew, the score, and which
rule accepted it. Both sample series stay exactly as their computers wrote them.
Canonical dives are then just the connected components of those links, which handles the
pool afternoon gracefully. One canonical dive with seven members, one Garmin log and six
Shearwater logs, nothing pretending the Perdix’s six dives didn’t happen.&lt;/p&gt;
&lt;p&gt;And 84 matches does not mean 84 out of 84. My Shearwater computers have logged 142 dives, and the 58
unmatched ones are all accounted for. Forty-nine happened on days I wasn’t
wearing the Garmin at all, mostly early confined-water sessions and a CCR (closed-circuit
rebreather) course where
my second computer was a Petrel 3 instead (the matcher only links Garmin to Shearwater,
so those two-Shearwater days stay unlinked, a limitation I haven’t needed to fix yet).
Eight are bench tests under two metres or two minutes. Exactly one, a six-minute
2.5 m bob during a surface interval on 2025-12-19, overlapped a real Garmin log and was
still rejected, correctly, by both rules.&lt;/p&gt;
&lt;p&gt;The residual skews are their own small dataset. The median is +20 s across my 84 pairs, 53 of
them within ±30 s, worst case +190 s on a day I apparently took my time pressing the
second button. If I ever want to know how my two computers’ crystals drift apart over
months, the numbers are sitting in a table, because correcting a disagreement in place
would have destroyed the record of it.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;/posts/verifying-undocumented-bytes&quot;&gt;verification suite&lt;/a&gt; closes the loop. After
alignment, the median depth disagreement across all 84 matched pairs, median-of-medians,
is 0.162 m. When I ask &lt;code&gt;bottomtime plot&lt;/code&gt; for dive #202, both profiles land on one time
axis using the stored offset and skew, no resampling, and the two lines sit on top of
each other.&lt;/p&gt;</content:encoded></item><item><title>Proving byte 25</title><link>https://snehankekre.com/posts/verifying-undocumented-bytes/</link><guid isPermaLink="true">https://snehankekre.com/posts/verifying-undocumented-bytes/</guid><description>A reverse-engineered byte is a hypothesis. I wanted mine attacked from every angle before trusting it: the vendor&apos;s exports, its own arithmetic, physics, a second computer, and finally its source code.</description><pubDate>Wed, 22 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In &lt;a href=&quot;/posts/decoding-pnf&quot;&gt;the last post&lt;/a&gt; I claimed that byte 25 of a Shearwater dive
sample is GF99 (the live readout of how close your tissue load is to the decompression
model’s limit), byte 24 is the deco ceiling, byte 18 is battery voltage, and bytes 26-27
are the @+5 prediction, the time to surface if you stayed five more minutes. Shearwater has never documented any of that. Which means the
claim is exactly as good as the evidence behind it, and “the curve looked right on a few
dives” is the kind of evidence that decodes your tissue loading wrong for a year before
you notice.&lt;/p&gt;
&lt;p&gt;So &lt;a href=&quot;https://github.com/snehankekre/bottomtime&quot;&gt;bottomtime&lt;/a&gt; treats every byte mapping as
a hypothesis and ships a verification suite, &lt;code&gt;bottomtime verify&lt;/code&gt;, that tries to falsify
all of them against every dive in the store. The suite has four independent oracles, and
a fifth turned up later from outside it. None of them is my own opinion of what a
plausible dive looks like.&lt;/p&gt;
&lt;h2 id=&quot;oracle-1-the-vendors-other-rendering&quot;&gt;Oracle 1: the vendor’s other rendering&lt;/h2&gt;
&lt;p&gt;Shearwater Cloud can export a dive as XML. The XML is lossy (it’s missing the very
channels I had to reverse), but the channels it &lt;em&gt;does&lt;/em&gt; carry were produced by
Shearwater’s own code reading the same native log. That makes it a per-sample answer
key for the documented offsets. If my decoder and their exporter disagree about sample
371’s depth, my decoder is the one that’s wrong.&lt;/p&gt;
&lt;p&gt;The suite pairs each XML file with its dive by start time and compares nine channels
sample by sample:&lt;/p&gt;
&lt;div style=&quot;overflow:auto&quot;&gt;

















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;XML field&lt;/th&gt;&lt;th&gt;decoded column&lt;/th&gt;&lt;th&gt;tolerance&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;currentDepth&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;depth_m&lt;/code&gt;&lt;/td&gt;&lt;td&gt;0.051 m&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;ttsMins&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;tts_min&lt;/code&gt;&lt;/td&gt;&lt;td&gt;0.001&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;firstStopDepth&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;stop_depth_m&lt;/code&gt;&lt;/td&gt;&lt;td&gt;0.11 m&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;fractionO2&lt;/code&gt; / &lt;code&gt;fractionHe&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;o2_pct&lt;/code&gt;, &lt;code&gt;he_pct&lt;/code&gt;&lt;/td&gt;&lt;td&gt;0.001&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;averagePPO2&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;avg_ppo2&lt;/code&gt;&lt;/td&gt;&lt;td&gt;0.011&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;waterTemp&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;temp_c&lt;/code&gt;&lt;/td&gt;&lt;td&gt;0.51 C&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;batteryVoltage&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;battery_v&lt;/code&gt;&lt;/td&gt;&lt;td&gt;0.011 V&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;currentNdl&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;ndl_min&lt;/code&gt;&lt;/td&gt;&lt;td&gt;0.001&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The tolerances exist because the two renderings round differently. The log stores depth
as decimetres and the XML prints it as a float, so half a decimetre of slack covers the
rounding without covering any real mistake. Byte 18 earns its place here too. The XML’s
&lt;code&gt;batteryVoltage&lt;/code&gt; equals the byte’s value divided by 100 on every sample, which is what
promoted it from guess to mapping.&lt;/p&gt;
&lt;p&gt;The current run verbatim against my 142 dives with Shearwater dive computers:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;xml crosscheck: 142 dives, worst deviation 0.0000 at #13[86].depth_m&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every shared sample of every dive agrees exactly. The “worst” deviation is a rounding
artifact so small it prints as zero.&lt;/p&gt;
&lt;h2 id=&quot;oracle-2-the-vendors-own-arithmetic&quot;&gt;Oracle 2: the vendor’s own arithmetic&lt;/h2&gt;
&lt;p&gt;The XML can’t vouch for GF99, since GF99 is one of the fields it omits. But Shearwater
Cloud computes a summary statistic per dive called &lt;code&gt;EndGF99&lt;/code&gt;, the gradient factor at
surfacing, and stores it in the database next to the raw blob. Shearwater’s code
computed that number from the same samples I’m decoding. So if byte 25 really is GF99, then
the maximum of my decoded byte-25 values over the last minutes of a dive should equal
their &lt;code&gt;EndGF99&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Across all 142 of my Shearwater dives:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/posts/verifying-undocumented-bytes/endgf99.png&quot; alt=&quot;Scatter of decoded max GF99 near surfacing against Shearwater Cloud&amp;#x27;s EndGF99 for 142 dives, all points on the diagonal&quot;&gt;&lt;/p&gt;
&lt;p&gt;The median disagreement is zero. The worst is a single GF point, and it shows up on two
dives. On one, Cloud reads 5 where I read 6. On the other, Cloud’s EndGF99 is 0 where my
tail reads 1. Both readings sit at the bottom of the scale, a leading tissue only a few
percent of the way to its Bühlmann limit, so a one-point gap there is the same clean
surfacing counted two ways. I
take the window over the last 60 samples (ten minutes) rather than the literal final
sample because GF99 keeps moving during the last metre of ascent, and Cloud’s snapshot
and mine need not land on the same instant.&lt;/p&gt;
&lt;h2 id=&quot;oracle-3-physics&quot;&gt;Oracle 3: physics&lt;/h2&gt;
&lt;p&gt;Some checks need no vendor at all. A decoded ceiling that comes out deeper than the
displayed stop depth would be nonsense. Deco stops land only on a 3 m grid (3 m, 6 m,
9 m…), and the stop is the ceiling pushed to the next grid depth at least as deep, so
the decoded ceiling can never sit deeper than the decoded stop. That invariant holds on all
33,773 samples in the store with zero violations. If I had picked the wrong byte for either
field, this is the check that would scream, because an arbitrary byte has no reason to
stay quantized-consistent with its neighbor for a hundred hours of diving.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;0xFF&lt;/code&gt; sentinel behaves physically too. On my 101.5 m dive, byte 25 reads &lt;code&gt;0xFF&lt;/code&gt;
from second 10 to second 1050, one contiguous run, and then never again. That span is
the descent and the deep segment. Tissues sit under ambient pressure the whole time, with
no supersaturation to report. The sentinel switches off exactly when the physics says a
gradient factor starts to exist.&lt;/p&gt;
&lt;h2 id=&quot;oracle-4-the-other-wrist&quot;&gt;Oracle 4: the other wrist&lt;/h2&gt;
&lt;p&gt;I log every dive on two computers, a Garmin Descent Mk3i and the Perdix. To my knowledge, they share no
firmware, sensor, nor vendor. After &lt;a href=&quot;/posts/matching-two-dive-computers&quot;&gt;the matcher&lt;/a&gt; aligns their clocks, both series claim
to describe the same physical body in the same
water column, so their depth channels have to agree. For each of the 84 matched pairs
the suite takes the median absolute depth difference at aligned timestamps:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;twin agreement: 84 matches, median-of-medians 0.162 m, worst 0.840 m&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The tolerance scales with depth, max(0.6 m, 4.5% of max depth), because the two
computers convert pressure to depth with their own assumed water density (fresh, salt,
or the EN13319 dive-gauge standard), and those differ by up to about 3%. Sixteen centimetres of median
disagreement between two unrelated instruments is, honestly, better than I expected
from physics that starts with “assume a density”.&lt;/p&gt;
&lt;h2 id=&quot;what-else-byte-25-could-have-been&quot;&gt;What else byte 25 could have been&lt;/h2&gt;
&lt;p&gt;A byte that merely correlates with GF99 would sail through a sloppy check, so the live
alternatives each needed disproving separately. The strongest was SurfGF, Shearwater’s
other gradient number, the same supersaturation recomputed as if I surfaced right now.
The two converge at the moment of surfacing, so the EndGF99 oracle alone can’t tell
them apart. The sentinel can. SurfGF is well-defined and climbing through the descent
and bottom phase, which is precisely the 1,040 seconds byte 25 spends reading &lt;code&gt;0xFF&lt;/code&gt;. CNS, the oxygen-toxicity clock,
was disproven twice. It’s already documented at byte 23, and it never decreases during a dive,
while byte 25 decays through every long stop. Timers, counters, and battery-like slow
channels fail the same shape test. GF99 is the only quantity I know of that is
undefined at depth, wakes on ascent, decays at stops, spikes at surfacing, and lands on
Shearwater’s own EndGF99 142 times out of 142.&lt;/p&gt;
&lt;h2 id=&quot;oracle-5-the-vendors-own-source&quot;&gt;Oracle 5: the vendor’s own source&lt;/h2&gt;
&lt;p&gt;The four oracles above are behavioral. They judge byte 25 by how it acts. A fifth arrived
later and judges it by construction. Shearwater Cloud ships its parser as a .NET
assembly, &lt;code&gt;DiveLogParser.dll&lt;/code&gt;, and the macOS app carries the same managed file as the
Windows one, so I could read the class that decodes these records
(&lt;a href=&quot;/posts/decoding-pnf&quot;&gt;the how is in the last post&lt;/a&gt;). It names byte 25 &lt;code&gt;GF99_OFFSET&lt;/code&gt; and
byte 24 &lt;code&gt;DECOCEILING_OFFSET&lt;/code&gt;, confirming all four empirical bytes at once.&lt;/p&gt;
&lt;p&gt;It also closed the SurfGF question harder than the sentinel did. The parse method only
treats byte 25 as GF99 when the deco model uses gradient factors, and reads it as a
safe-ascent-depth fraction on DCIEM, a decompression model that doesn’t use gradient
factors. A generic gradient number like SurfGF would never
get that branch, which pins byte 25 as GF99 in particular and closes the one gap the
behavioral oracles left open. That is the strongest evidence I have, which is why I put
it last. If I’d read the source first, I’d have trusted a name and
skipped the checks that would have caught me decoding the DCIEM case wrong. The source
tells you what the bytes are called. Only the data tells you whether your code is right.&lt;/p&gt;
&lt;h2 id=&quot;byte-11-which-no-oracle-could-reach&quot;&gt;Byte 11, which no oracle could reach&lt;/h2&gt;
&lt;p&gt;Byte 11 defeated all four behavioral oracles. It was nonzero in 32,544 of my 33,773
samples, sat between 82 and 94 through the deep dive, and correlated with no exported
field, computed summary, invariant, or second computer. For a long time it stayed raw in an
&lt;code&gt;extra_json&lt;/code&gt; column, unlabeled, as one of 392,959 undecoded payloads the store keeps
verbatim. The fifth oracle is what finally named it as &lt;code&gt;BATTERY_PERCENT_REMAINING&lt;/code&gt;. And
because a name in a binary is still a hypothesis, I checked it the same way as the rest,
and the values bound cleanly to 0-100 across every sample, which an arbitrary byte has no
reason to do. While behavior can pin some bytes down, the source has to name the rest, and both
still have to survive the data.&lt;/p&gt;
&lt;p&gt;The discipline underneath all of it is that decoded and verified are different states,
and only the checks are allowed to move a byte from one to the other. What I never do is
guess plausibly and move on, because a dive log is the one dataset where
plausible-but-wrong is worse than missing. Nobody re-checks a number that looks right.&lt;/p&gt;
&lt;h2 id=&quot;every-trip-re-litigates-everything&quot;&gt;Every trip re-litigates everything&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;bottomtime verify&lt;/code&gt; runs against the live store, so every new trip re-litigates every
mapping: 142 dives of XML agreement, 142 EndGF99 convergences, 33,773 invariant samples,
84 twin comparisons, and the counts and foreign keys besides. The empirical bytes are
validated on a Perdix 2 and a Petrel 3 so far. Every mapping that clears the suite is
what the &lt;a href=&quot;https://github.com/snehankekre/pnf/blob/main/SPEC.md&quot;&gt;pnf spec&lt;/a&gt; writes down and
the &lt;code&gt;pnf&lt;/code&gt; package (&lt;code&gt;pip install pnf&lt;/code&gt;) decodes. If your Teric or Nerd 2 populates them
differently, the suite is designed to catch it rather than silently mis-decode, and a
failing blob in &lt;a href=&quot;https://github.com/snehankekre/bottomtime/issues&quot;&gt;the issue tracker&lt;/a&gt;
is the data I can’t generate myself. So please, if you have a Shearwater that disagrees with my mapping, run &lt;code&gt;bottomtime verify&lt;/code&gt; and
file the blob.&lt;/p&gt;</content:encoded></item><item><title>The bytes my dive computer keeps to itself</title><link>https://snehankekre.com/posts/decoding-pnf/</link><guid isPermaLink="true">https://snehankekre.com/posts/decoding-pnf/</guid><description>Shearwater Cloud stores every dive as a gzip blob of 32-byte records, then exports a subset of it. GF99, deco ceiling, and battery voltage stay behind. So I decoded the blobs myself.</description><pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;link rel=&quot;stylesheet&quot; href=&quot;/posts/decoding-pnf/pnf.css&quot;&gt;
&lt;p&gt;My &lt;a href=&quot;https://shearwater.com/pages/perdix-2-support&quot;&gt;Shearwater Perdix 2&lt;/a&gt; computes a number called GF99 every ten seconds of every dive. It’s the
leading tissue’s supersaturation as a percentage of its Bühlmann M-value at my current
depth. In plainer words, it says how close my dissolved gas load is to the decompression
model’s limit, where 100 means I’m standing on the line. It’s one of the most interesting
numbers a dive computer produces, a live readout of how loaded I actually am, and
Shearwater Cloud plots it. But the XML export, the thing
you’d reach for to get your data out, doesn’t contain it, and neither does the CSV. Same
for the deco ceiling, the per-sample battery voltage, and the @+5 prediction. The app is
clearly reading them from somewhere.&lt;/p&gt;
&lt;p&gt;So I went looking. This post is about what I
found: Shearwater’s Petrel Native Format, the 32-byte record stream every Petrel-family
computer logs internally, and the four bytes in it I first mapped by experiment and only
later confirmed against Shearwater’s own code. The decoder ships in
&lt;a href=&quot;https://github.com/snehankekre/bottomtime&quot;&gt;bottomtime&lt;/a&gt;, the dive-log store I wrote for
my two-computer logging habit (&lt;code&gt;pip install bottomtime&lt;/code&gt;).&lt;/p&gt;
&lt;h2 id=&quot;where-the-real-log-lives&quot;&gt;Where the real log lives&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://shearwater.com/pages/shearwater-cloud&quot;&gt;Shearwater Cloud&lt;/a&gt;’s desktop app has three export options. XML and CSV are lossy renderings.
The third, “Export Database”, writes a SQLite file called &lt;code&gt;dive_data.db&lt;/code&gt;, and that one
contains the real log. Inside there’s a &lt;code&gt;log_data&lt;/code&gt; table with a BLOB column, one blob per dive,
tagged &lt;code&gt;sw-pnf&lt;/code&gt;. The blob layout is a 4-byte little-endian length followed by a gzip
stream:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;expected &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; struct.unpack_from(&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;&amp;#x3C;I&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, blob, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;data &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; gzip.decompress(blob[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;4&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;:])&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;assert&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; len&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(data) &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; expected&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;assert&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; len&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(data) &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;%&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 32&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; ==&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For my dive #202 (101.5 m at Drop Off dive site Tulamben, 102 minutes runtime), the blob is 16,388 bytes,
the prefix promises 26,496, and gzip delivers it. That unpacks to 828 records of 32 bytes.
The whole dive which is one record every 10 seconds plus bookkeeping, is compressed smaller than a
photo on your phone. Wow \(*o*)/!&lt;/p&gt;
&lt;p&gt;Byte 0 of each record is its type. In that dive the stream looks like this:&lt;/p&gt;
&lt;div style=&quot;overflow:auto&quot;&gt;












































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;type&lt;/th&gt;&lt;th&gt;meaning&lt;/th&gt;&lt;th&gt;count in dive #202&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;0x01&lt;/code&gt;&lt;/td&gt;&lt;td&gt;dive sample&lt;/td&gt;&lt;td&gt;616&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;0x10&lt;/code&gt;-&lt;code&gt;0x19&lt;/code&gt;&lt;/td&gt;&lt;td&gt;opening (config) records&lt;/td&gt;&lt;td&gt;1 each&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;0x20&lt;/code&gt;-&lt;code&gt;0x29&lt;/code&gt;&lt;/td&gt;&lt;td&gt;closing records&lt;/td&gt;&lt;td&gt;1 each&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;0x30&lt;/code&gt;&lt;/td&gt;&lt;td&gt;info event&lt;/td&gt;&lt;td&gt;7&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;0x00&lt;/code&gt;&lt;/td&gt;&lt;td&gt;all-zero padding, skipped&lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;0xFF&lt;/code&gt;&lt;/td&gt;&lt;td&gt;final record (serial, model, firmware)&lt;/td&gt;&lt;td&gt;1&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;0x51&lt;/code&gt;, &lt;code&gt;0x70&lt;/code&gt;-&lt;code&gt;0x75&lt;/code&gt;, &lt;code&gt;0x80&lt;/code&gt;-&lt;code&gt;0x87&lt;/code&gt;, &lt;code&gt;0xA0&lt;/code&gt;, &lt;code&gt;0xA1&lt;/code&gt;&lt;/td&gt;&lt;td&gt;no idea (yet)&lt;/td&gt;&lt;td&gt;180&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;The opening records carry the dive’s configuration: the ten programmed gases, gradient
factor settings, units, water density, atmospheric pressure, sample interval. The
closing records mirror them with end-of-dive state.&lt;/p&gt;
&lt;p&gt;The last row is the one I care about. Every record type the decoder doesn’t understand
is stored verbatim in the database, per dive, in stream order. Nothing is dropped just
because I can’t read it today.&lt;/p&gt;
&lt;h2 id=&quot;standing-on-libdivecomputer&quot;&gt;Standing on libdivecomputer&lt;/h2&gt;
&lt;p&gt;Luckily, I didn’t have to start from scratch. The open-source
&lt;a href=&quot;https://github.com/libdivecomputer/libdivecomputer&quot;&gt;libdivecomputer&lt;/a&gt; project has
supported Petrel-family computers for years, and its
&lt;a href=&quot;https://github.com/libdivecomputer/libdivecomputer/blob/master/src/shearwater_predator_parser.c&quot;&gt;&lt;code&gt;shearwater_predator_parser.c&lt;/code&gt;&lt;/a&gt;
handles PNF explicitly. A comment in the parser notes that relative to the legacy
Predator format “the samples are simply offset by one”, because PNF prepends the
record-type byte, and it documents the sample layout: depth in the first two bytes of
a sample, then next stop, TTS, gas fractions, temperature, CNS. Reading that parser is how
I knew what the documented bytes were, and how to check my own mapping against them.&lt;/p&gt;
&lt;p&gt;That parser is as close to a public spec as PNF gets. Shearwater does write protocol
documentation, but hands it only to registered developers through
&lt;a href=&quot;https://shearwater.com/pages/support&quot;&gt;a formal process&lt;/a&gt;.
&lt;a href=&quot;https://subsurface-divelog.org/&quot;&gt;Subsurface&lt;/a&gt; imports the desktop database by
&lt;a href=&quot;https://github.com/subsurface/subsurface/blob/master/core/import-shearwater.cpp&quot;&gt;querying its pre-decoded tables&lt;/a&gt;
and never opens the blob. So for the undocumented bytes, there was nothing left to look
up.&lt;/p&gt;
&lt;p&gt;Lay the documented offsets over a PNF sample record and 23 of its 32 bytes are
accounted for, counting the type byte. The other nine are either padding or they’re the
fields the exports omit, and Shearwater Cloud clearly reads &lt;em&gt;something&lt;/em&gt; more, because
it plots GF99.&lt;/p&gt;
&lt;h2 id=&quot;reading-the-record-byte-by-byte&quot;&gt;Reading the record, byte by byte&lt;/h2&gt;
&lt;p&gt;Here is the actual record from the deepest sample of dive #202, the moment the gauge
read 101.5 m. Hover (or tap) any byte:&lt;/p&gt;
&lt;div class=&quot;pnf-legend&quot;&gt;
  &lt;span class=&quot;pl pl-doc&quot;&gt;documented (libdivecomputer)&lt;/span&gt;
  &lt;span class=&quot;pl pl-emp&quot;&gt;found empirically&lt;/span&gt;
  &lt;span class=&quot;pl pl-unk&quot;&gt;named later, from Shearwater&apos;s own DLL&lt;/span&gt;
&lt;/div&gt;
&lt;div class=&quot;pnf-grid&quot;&gt;
  &lt;span class=&quot;b doc f-type&quot; tabindex=&quot;0&quot; data-t=&quot;byte 0: record type 0x01 = dive sample&quot;&gt;01&lt;/span&gt;
  &lt;span class=&quot;b doc f-depth&quot; tabindex=&quot;0&quot; data-t=&quot;bytes 1-2: depth, u16 BE. 0x03F7 = 1015, /10 = 101.5 m&quot;&gt;03&lt;/span&gt;
  &lt;span class=&quot;b doc f-depth&quot; tabindex=&quot;0&quot; data-t=&quot;bytes 1-2: depth, u16 BE. 0x03F7 = 1015, /10 = 101.5 m&quot;&gt;f7&lt;/span&gt;
  &lt;span class=&quot;b doc f-stop&quot; tabindex=&quot;0&quot; data-t=&quot;bytes 3-4: next stop depth, u16 BE = 33 m&quot;&gt;00&lt;/span&gt;
  &lt;span class=&quot;b doc f-stop&quot; tabindex=&quot;0&quot; data-t=&quot;bytes 3-4: next stop depth, u16 BE = 33 m&quot;&gt;21&lt;/span&gt;
  &lt;span class=&quot;b doc f-tts&quot; tabindex=&quot;0&quot; data-t=&quot;bytes 5-6: time to surface, u16 BE = 53 min&quot;&gt;00&lt;/span&gt;
  &lt;span class=&quot;b doc f-tts&quot; tabindex=&quot;0&quot; data-t=&quot;bytes 5-6: time to surface, u16 BE = 53 min&quot;&gt;35&lt;/span&gt;
  &lt;span class=&quot;b doc f-ppo2&quot; tabindex=&quot;0&quot; data-t=&quot;byte 7: average ppO2. 0x79 = 121, /100 = 1.21 ata&quot;&gt;79&lt;/span&gt;
  &lt;span class=&quot;b doc f-o2&quot; tabindex=&quot;0&quot; data-t=&quot;byte 8: O2 fraction = 11%&quot;&gt;0b&lt;/span&gt;
  &lt;span class=&quot;b doc f-he&quot; tabindex=&quot;0&quot; data-t=&quot;byte 9: He fraction. 0x3F = 63%&quot;&gt;3f&lt;/span&gt;
  &lt;span class=&quot;b doc f-stoptime&quot; tabindex=&quot;0&quot; data-t=&quot;byte 10: stop time = 1 min&quot;&gt;01&lt;/span&gt;
  &lt;span class=&quot;b unk f-b11&quot; tabindex=&quot;0&quot; data-t=&quot;byte 11: battery percent remaining (named in Shearwater&amp;#x27;s DLL). 0x5B = 91% here&quot;&gt;5b&lt;/span&gt;
  &lt;span class=&quot;b doc f-flags&quot; tabindex=&quot;0&quot; data-t=&quot;byte 12: status flags. 0x12 = OC mode + external ppO2&quot;&gt;12&lt;/span&gt;
  &lt;span class=&quot;b doc f-s1&quot; tabindex=&quot;0&quot; data-t=&quot;byte 13: ppO2 sensor 1 raw (0 here, this dive is OC)&quot;&gt;00&lt;/span&gt;
  &lt;span class=&quot;b doc f-temp&quot; tabindex=&quot;0&quot; data-t=&quot;byte 14: water temperature, signed = 25 C&quot;&gt;19&lt;/span&gt;
  &lt;span class=&quot;b doc f-s2&quot; tabindex=&quot;0&quot; data-t=&quot;byte 15: ppO2 sensor 2 raw&quot;&gt;00&lt;/span&gt;
  &lt;span class=&quot;b doc f-s3&quot; tabindex=&quot;0&quot; data-t=&quot;byte 16: ppO2 sensor 3 raw&quot;&gt;00&lt;/span&gt;
  &lt;span class=&quot;b unk f-b17&quot; tabindex=&quot;0&quot; data-t=&quot;byte 17: battery voltage high byte. Value is (byte17&lt;&lt;8 | byte18)/100; zero here because 1.5 V fits in one byte&quot;&gt;00&lt;/span&gt;
  &lt;span class=&quot;b emp f-batt&quot; tabindex=&quot;0&quot; data-t=&quot;byte 18: battery voltage low byte. (0x00&lt;&lt;8 | 0x98)/100 = 1.52 V&quot;&gt;98&lt;/span&gt;
  &lt;span class=&quot;b doc f-sp&quot; tabindex=&quot;0&quot; data-t=&quot;byte 19: CC setpoint (0.70 here; ignored on OC)&quot;&gt;46&lt;/span&gt;
  &lt;span class=&quot;b doc f-tank1&quot; tabindex=&quot;0&quot; data-t=&quot;bytes 20-21: tank 1 pressure (AI). 0xFFFF = no transmitter&quot;&gt;ff&lt;/span&gt;
  &lt;span class=&quot;b doc f-tank1&quot; tabindex=&quot;0&quot; data-t=&quot;bytes 20-21: tank 1 pressure (AI). 0xFFFF = no transmitter&quot;&gt;ff&lt;/span&gt;
  &lt;span class=&quot;b doc f-gtr&quot; tabindex=&quot;0&quot; data-t=&quot;byte 22: gas time remaining. 0xFF = n/a&quot;&gt;ff&lt;/span&gt;
  &lt;span class=&quot;b doc f-cns&quot; tabindex=&quot;0&quot; data-t=&quot;byte 23: CNS = 4%&quot;&gt;04&lt;/span&gt;
  &lt;span class=&quot;b emp f-ceil&quot; tabindex=&quot;0&quot; data-t=&quot;byte 24: deco ceiling. 0x1F = 31 m&quot;&gt;1f&lt;/span&gt;
  &lt;span class=&quot;b emp f-gf99&quot; tabindex=&quot;0&quot; data-t=&quot;byte 25: GF99 on gradient-factor models. 0xFF = not available (tissues still on-gassing). On DCIEM this byte is a safe-ascent-depth fraction instead&quot;&gt;ff&lt;/span&gt;
  &lt;span class=&quot;b emp f-at5&quot; tabindex=&quot;0&quot; data-t=&quot;bytes 26-27: @+5, u16 BE = 85 min (TTS if I stayed 5 more minutes)&quot;&gt;00&lt;/span&gt;
  &lt;span class=&quot;b emp f-at5&quot; tabindex=&quot;0&quot; data-t=&quot;bytes 26-27: @+5, u16 BE = 85 min (TTS if I stayed 5 more minutes)&quot;&gt;55&lt;/span&gt;
  &lt;span class=&quot;b doc f-tank0&quot; tabindex=&quot;0&quot; data-t=&quot;bytes 28-29: tank 0 pressure (AI). 0xFFFF = no transmitter&quot;&gt;ff&lt;/span&gt;
  &lt;span class=&quot;b doc f-tank0&quot; tabindex=&quot;0&quot; data-t=&quot;bytes 28-29: tank 0 pressure (AI). 0xFFFF = no transmitter&quot;&gt;ff&lt;/span&gt;
  &lt;span class=&quot;b unk f-b30&quot; tabindex=&quot;0&quot; data-t=&quot;bytes 30-31: surface air consumption (SAC), u16 / 100. Shearwater&amp;#x27;s constant names it RESPIRATORY_MINUTE_VOLUME. 0xFFFF = n/a; I dive without gas integration&quot;&gt;ff&lt;/span&gt;
  &lt;span class=&quot;b unk f-b30&quot; tabindex=&quot;0&quot; data-t=&quot;bytes 30-31: surface air consumption (SAC), u16 / 100. Shearwater&amp;#x27;s constant names it RESPIRATORY_MINUTE_VOLUME. 0xFFFF = n/a; I dive without gas integration&quot;&gt;ff&lt;/span&gt;
&lt;/div&gt;
&lt;p&gt;The documented bytes check out against what my wrist showed at 101.5 m: 11/63 trimix,
TTS 53 minutes, next stop 33 m, CNS 4%, water 25 C. The arithmetic is nothing fancier
than big-endian shorts and a divide by ten.&lt;/p&gt;
&lt;h2 id=&quot;the-four-bytes-i-had-to-find-myself&quot;&gt;The four bytes I had to find myself&lt;/h2&gt;
&lt;p&gt;Bytes 18, 24, 25 and 26-27 are the ones libdivecomputer doesn’t read and the exports
don’t include. Mapping them was an empirical process where I had to stare at the values across
hundreds of dives, form a hypothesis, and try to falsify it.&lt;/p&gt;
&lt;p&gt;Byte 25 was an interesting light bulb moment. It reads &lt;code&gt;0xFF&lt;/code&gt; early in a dive, wakes up as tissues load, decays
during long shallow stops, and spikes right at surfacing. That is exactly how GF99 should
behave, and it converges on the &lt;code&gt;EndGF99&lt;/code&gt; value Shearwater Cloud computes for each dive.
Byte 24 hugs the depth profile from below during deco, always sitting at or just under
the 3 m-quantized stop depth, and zeroes the moment deco clears. That’s the ceiling!
Byte 18 matched the XML export’s &lt;code&gt;batteryVoltage&lt;/code&gt; times 100 on every sample I checked.
Bytes 26-27 tracked TTS but ran ahead of it by a sensible margin, which is the @+5
display field, TTS if I stayed five more minutes.&lt;/p&gt;
&lt;p&gt;Here’s dive #202 with the empirical bytes plotted. Ceiling in red riding under the grey
quantized stop depth and GF99 waking up at the 17-minute mark once the tissues actually
have something to report:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/posts/decoding-pnf/gf99-ceiling.png&quot; alt=&quot;Depth, stop depth and decoded ceiling for a 101.5 m dive, with GF99 below, showing the 0xFF span during on-gassing&quot;&gt;&lt;/p&gt;
&lt;p&gt;But a curve that behaves right is still circumstantial. Hmm  (╭ರ_•́)..&lt;/p&gt;
&lt;p&gt;So I wrote a verification suite that cross-checks every decoded channel, per sample,
against Shearwater’s own outputs across all 142 of my Shearwater dives, and it deserves
&lt;a href=&quot;/posts/verifying-undocumented-bytes&quot;&gt;its own post&lt;/a&gt;. Short version: the worst
disagreement between my decoder and Shearwater’s XML across every shared sample is
0.0000, and byte 25’s surfacing value agrees with Cloud’s EndGF99 on 142 of 142 dives
within one GF point.&lt;/p&gt;
&lt;h2 id=&quot;then-i-read-shearwaters-own-code&quot;&gt;Then I read Shearwater’s own code&lt;/h2&gt;
&lt;p&gt;I could have stopped at the verification suite. Then a &lt;a href=&quot;https://www.reddit.com/r/AskReverseEngineering/comments/1gq9vth/comment/mbtywtv/?context=3&quot;&gt;Reddit comment&lt;/a&gt; pointed out that
Shearwater Cloud ships its parser as a .NET assembly, &lt;code&gt;DiveLogParser.dll&lt;/code&gt;, and that you
can open the class &lt;code&gt;PetrelNativeLogV14Parser&lt;/code&gt; in a decompiler and read the format
directly. That’s a Windows workflow, and I’m on a Mac, but the macOS build of Shearwater
Cloud is a Unity app and it ships the same managed DLL under
&lt;code&gt;Shearwater Cloud.app/Contents/Resources/Data/Managed/&lt;/code&gt;. It turned out to be real
.NET bytecode which I could read.&lt;/p&gt;
&lt;p&gt;The byte offsets are stored as named constants right in the assembly metadata, so I
didn’t even need a decompiler. &lt;a href=&quot;https://github.com/malwarefrank/dnfile&quot;&gt;dnfile&lt;/a&gt;, a
pure-Python reader, dumps them straight out. Here is what Shearwater calls the bytes I
had mapped by hand:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;GF99_OFFSET                = 25&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;DECOCEILING_OFFSET         = 24&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;BATTERYVOLTAGE_OFFSET_LSB  = 18&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;ATPLUSFIVE_1ST_BYTE_OFFSET = 26&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four for four, sweet! And three bytes I’d filed under “no idea” got names in the same table.
Byte 11 is &lt;code&gt;BATTERY_PERCENT_REMAINING&lt;/code&gt;, which is why it sat in the eighties and nineties
and, I checked, never once exceeds 100 across all 32,544 samples. Byte 17 is
&lt;code&gt;BATTERYVOLTAGE_OFFSET_MSB&lt;/code&gt;, the high byte of a sixteen-bit battery voltage whose low
byte is 18, reading zero only because 1.5 volts never needs the high byte. Bytes 30-31
hold surface air consumption. Shearwater’s constant calls it
&lt;code&gt;RESPIRATORY_MINUTE_VOLUME&lt;/code&gt; but the parse method reads it as SAC and divides by 100.
Either way it’s &lt;code&gt;0xFFFF&lt;/code&gt; for me, since I dive without gas integration.&lt;/p&gt;
&lt;p&gt;Decompiling the actual parse method with &lt;a href=&quot;https://github.com/icsharpcode/ILSpy&quot;&gt;ILSpy&lt;/a&gt;
caught something that wasn’t obvious from the constants alone. Bytes 24 and 25 are ceiling and GF99 only when
the decompression model uses gradient factors:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;csharp&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (headerDecoModel &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 3&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) {   &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;// DCIEM&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    byte&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; b&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; logData[offset &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 24&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    float&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; frac&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; logData[offset &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 25&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 100f&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    sample.SafeAscentDepth &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; b &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; frac;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;} &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;else&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {                      &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;// GF, VPM-B, VPM-B/GFS&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    sample.DecoCeiling &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; logData[offset &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 24&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    sample.Gf99 &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; logData[offset &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 25&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On a DCIEM dive those two bytes are a safe-ascent depth instead, and my decoder, which
reads byte 25 as GF99 no matter what, would mis-decode it. All 142 of my dives run
gradient factors, so the bug has never fired. My data just never contained the case that breaks my code.
It’s on the fix list now though, found by reading the vendor instead of my own logs lol.&lt;/p&gt;
&lt;h2 id=&quot;whats-still-dark&quot;&gt;What’s still dark&lt;/h2&gt;
&lt;p&gt;Even the record types I’d shrugged at earlier were mostly named once I looked. &lt;code&gt;0x70&lt;/code&gt;-&lt;code&gt;0x75&lt;/code&gt; are
tissue-loading snapshots, &lt;code&gt;0x80&lt;/code&gt;-&lt;code&gt;0x88&lt;/code&gt; are validity bitmaps for the opening and sample
records, &lt;code&gt;0x60&lt;/code&gt; is a surface ppO2 log. Three types in my dives are still unexplained.
&lt;code&gt;0x51&lt;/code&gt;, &lt;code&gt;0xA0&lt;/code&gt;, and &lt;code&gt;0xA1&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;They stay in the &lt;code&gt;undecoded_payloads&lt;/code&gt; table (392,959 rows and counting, Garmin’s mystery
FIT messages included), verbatim, because the blobs are archived and the decoder is
versioned. When someone works one out, I re-decode from the archive and every past dive
gains the channel, no re-download needed, even if the computer that logged it is long
dead by then. That archive-first design is
its own story.&lt;/p&gt;
&lt;p&gt;I wrote all of this up as a standalone
&lt;a href=&quot;https://github.com/snehankekre/pnf/blob/main/SPEC.md&quot;&gt;format specification&lt;/a&gt;, and pulled
the decoder into its own pure-Python package for anyone who wants to read Petrel logs
without the rest of my dive store: &lt;a href=&quot;https://github.com/snehankekre/pnf&quot;&gt;pnf&lt;/a&gt;
(&lt;code&gt;pip install pnf&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;The mappings above are validated on a Perdix 2 and a Petrel 3 (the latter driving a
Choptima CCR). Other models in the family may populate these bytes differently. If yours
disagrees, &lt;code&gt;bottomtime verify&lt;/code&gt; will say so loudly, and I would love the failing
blob. &lt;a href=&quot;https://github.com/snehankekre/bottomtime/issues&quot;&gt;Issues welcome&lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>My homepage has a pulse</title><link>https://snehankekre.com/posts/hr/</link><guid isPermaLink="true">https://snehankekre.com/posts/hr/</guid><description>There&apos;s a small red heart on my homepage now, beating at my actual heart rate with an exactly 24 hour delay. How I get the data out of my Garmin, and where the white lie is.</description><pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;There is a small red heart on my homepage now that beats. The number next to it is my actual
heart rate, and the heart pulses at that frequency: 48 bpm means one thump every 1.25 seconds,
80 bpm means one every 0.75. I wrote this up because I wanted to, and because the design ended
up containing more interesting decisions than I expected.&lt;/p&gt;
&lt;h2 id=&quot;the-watch&quot;&gt;The watch&lt;/h2&gt;
&lt;p&gt;I wear a &lt;a href=&quot;https://www.garmin.com/en-US/p/852217/&quot;&gt;Garmin Descent Mk3i&lt;/a&gt;, 43 mm, carbon gray DLC
titanium. I bought it as my primary dive computer, alongside my Shearwater Perdix 2, because you
need redundancy in technical diving and overhead environments like caves and wrecks. If a computer dies
at 75 m inside a cave, you can’t just surface and charge it. Cave divers have backups for their backups.
Since April 2025 it has been on 400+ dives with me, including to 100 m.&lt;/p&gt;
&lt;p&gt;I don’t take it off unless I’m charging it or showering. Between dives it tracks my heart
rate, sleep score, HRV, steps, calories, and a dozen other vitals around the clock, and syncs
everything to the &lt;a href=&quot;https://www.garmin.co.id/products/apps/garmin-connect-mobile/&quot;&gt;Garmin Connect app&lt;/a&gt;
on my Pixel 9 Pro. My gym and streetlifting sessions get logged on it too. Which means Garmin already
holds a continuous, granular physiological record of my life. I just needed the data out of Garmin and onto my homepage.&lt;/p&gt;
&lt;h2 id=&quot;a-more-personal-online-status-indicator&quot;&gt;A more personal “online” status indicator&lt;/h2&gt;
&lt;p&gt;Old messaging apps had the green dot. Facebook Messenger, Gchat before it: a small indicator
that said this person is here right now. I remember seeing it in HexChat in 2012, before I
moved to Irssi. I wanted that for this site, except the green dot always felt like a statement about a socket
instead of a person. A heartbeat is the least abstract “online” there is. It’s the difference
between “his browser has a connection open” and “he is alive, and here is the proof”. In the end
it’s just a number on a page, I get that. But it is my number, from my chest, and that makes it
the most personal thing on the site. It might be a little cringe to a lot of you, but I like the idea :P.&lt;/p&gt;
&lt;p&gt;The longer-term goal, when I get around to it, is a section of this site that shows a granular ledger of
my life made public: workouts, sleep, HRV, calories burned, steps walked, etc. I
understand what that leaks. All-day heart rate reveals when I sleep and wake. Gaps and
timezone shifts reveal travel. A resting heart rate that jumps 8 bpm flags illness, stress,
or last night’s drinks. HRV trends are a decent proxy for mental state, and a regular gym
schedule advertises exactly when I am not home. I have sat with all of that and decided I am
fine with it. I consent to it, and if you have a problem with it, that is your problem, not mine ¯\&lt;em&gt;(ツ)&lt;/em&gt;/¯.&lt;/p&gt;
&lt;h2 id=&quot;surely-someone-has-done-this-yup-they-have&quot;&gt;Surely someone has done this. Yup, they have.&lt;/h2&gt;
&lt;p&gt;My first thought was that this must already exist, and I was right. Twitch streamers have been
putting live heart rate on screen for years through services like
&lt;a href=&quot;https://blog.pulsoid.net/post/garmin-devices-supported-by-pulsoid&quot;&gt;Pulsoid&lt;/a&gt; and
&lt;a href=&quot;https://www.hyperate.io/&quot;&gt;HypeRate&lt;/a&gt;, and both support Garmin watches. They work by putting
the wearable into its
&lt;a href=&quot;https://support.garmin.com/en-US/?faq=Zj1947s6pqAHzBCAhLhrC9&quot;&gt;broadcast heart rate&lt;/a&gt; mode,
where it streams over BLE/ANT+ to a phone app that relays the number to their servers. Seemed
promising at first, but it was a deadend for me. Garmin’s manuals warn that broadcasting decreases
battery life, and this watch is my dive computer. I’m not going to trade dive-day battery for a
website widget. And broadcast is a mode you switch on and stay in range of. Seems like too much
of a chore to me. Plus, I like that I charge the watch once a week and it lasts for 10 days.
I don’t want to charge it every night just to keep a widget alive.&lt;/p&gt;
&lt;p&gt;The browser itself can speak Bluetooth, which briefly seemed promising. The
&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API&quot;&gt;Web Bluetooth API&lt;/a&gt; can
read the standard heart rate GATT service directly from a nearby device. Still a no-go because
it connects the visitor’s browser to a device near the visitor. It would only ever work on my own machine,
in a Chromium browser, with the watch in the broadcast mode.&lt;/p&gt;
&lt;p&gt;Surely then there is a real API. There is: Garmin’s
&lt;a href=&quot;https://developer.garmin.com/gc-developer-program/health-api/&quot;&gt;Health API&lt;/a&gt; serves
this data, BUT access is gated behind a business application and review. It is built for
companies integrating wearables into products. What about a guy who wants a heart on his
homepage? Fahhhh (╯’□’)╯︵ ┻━┻.&lt;/p&gt;
&lt;p&gt;At this point I did what I should have done first and searched GitHub and Reddit. Two
projects kept surfacing in Reddit threads. &lt;a href=&quot;https://github.com/tcgoetz/GarminDB&quot;&gt;GarminDB&lt;/a&gt;
downloads your entire Garmin history into a local SQLite database and gives you analysis and
plots on top. Cool, but wrong for this. An archive with a schema seems like overkill… I probably just want
one day of heart rate as JSON every morning.
&lt;a href=&quot;https://github.com/cyberjunky/python-garminconnect&quot;&gt;python-garminconnect&lt;/a&gt; is the right
tool because it is a Python client that speaks the same private API the Garmin Connect app uses.
It lets you log in the way the app does and calls the same endpoints as the app. It exposes essentially everything
I can see on my phone, which incidentally solves the someday-in-the-future-project problem, because
workouts, sleep, and HRV are all reachable from the same client. Nice, thank you &lt;a href=&quot;https://github.com/cyberjunky&quot;&gt;@cyberjunky&lt;/a&gt;!&lt;/p&gt;
&lt;h2 id=&quot;why-not-live&quot;&gt;Why not live?&lt;/h2&gt;
&lt;p&gt;Live was never actually on the table, but I didn’t know that at the start. The watch does
not stream anywhere by default: all-day heart rate accumulates on the wrist and reaches
Garmin’s servers in batches, whenever the watch↔phone sync happens. Without broadcast mode there is no
feed to subscribe to. And my site is static, Astro built once and served as files from GitHub
Pages. There’s no server to hold a websocket open, so even if the watch did stream, I didn’t figure out
a way to relay it to visitors. The only option is to fetch the data once a day and replay it.&lt;/p&gt;
&lt;p&gt;So the compromise is that the widget replays yesterday. When you load my homepage at 14:32 your time, you see my
heart rate at 14:32 yesterday, interpolated from real samples. The “live” label is a white lie,
but it’s mine to tell. But at least the lie is precise: the offset is exactly 24 hours,
the data is real, and nothing is made up beyond drawing straight lines between measurements.&lt;/p&gt;
&lt;h2 id=&quot;a-one-time-login-for-a-year&quot;&gt;A one-time login (for a year)&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;python-garminconnect&lt;/code&gt; logs in with an email and password, but what it hands back is a pair of
OAuth tokens. The OAuth1 token is the long-lived one, good
for about a year. The OAuth2 token is what actually authorizes API calls, and it expires
after a few hours. The library signs every request with the OAuth2 token, and whenever that
one expires, it uses the OAuth1 token to generate a fresh one behind the scenes (my limited understanding). So my creds
get typed once on my laptop, and never go anywhere near CI. CI only holds
tokens which refresh themselves for a year.&lt;/p&gt;
&lt;p&gt;The one-time part looks like this:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;python3&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; -m&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; venv&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; .venv&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;source&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; .venv/bin/activate.fish&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;pip3&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; install&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; garminconnect&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;python3&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; scripts/garmin_login.py&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/snehankekre/snehankekre.github.io/blob/27b39defb12186efca3312041d83dd2e4e1d7c5f/scripts/garmin_login.py&quot;&gt;&lt;code&gt;garmin_login.py&lt;/code&gt;&lt;/a&gt;
is a short script that prompts for credentials and handles the token plumbing. The
interesting part:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;garmin &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; Garmin(&lt;/span&gt;&lt;span style=&quot;color:#E36209;--shiki-dark:#FFAB70&quot;&gt;email&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;email, &lt;/span&gt;&lt;span style=&quot;color:#E36209;--shiki-dark:#FFAB70&quot;&gt;password&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;password, &lt;/span&gt;&lt;span style=&quot;color:#E36209;--shiki-dark:#FFAB70&quot;&gt;return_on_mfa&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;True&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;status, client_state &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; garmin.login()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; status &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; &quot;needs_mfa&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    mfa &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; input&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;MFA code: &quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    garmin.resume_login(client_state, mfa)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;garmin.client.dump(&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;str&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(tokendir))            &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# token files -&gt; ~/.garminconnect&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;b64_path.write_text(garmin.client.dumps())   &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# same tokens, one base64 string&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;return_on_mfa=True&lt;/code&gt; makes the library pause and hand control back instead of dying when
Garmin asks for a two-factor code, and &lt;code&gt;resume_login&lt;/code&gt; finishes the handshake with whatever I
type. At the end the tokens land in two forms: as files in &lt;code&gt;~/.garminconnect&lt;/code&gt; for local runs,
and as a single base64 blob (&lt;code&gt;client.dumps()&lt;/code&gt; serializes the whole token store into one
string) that becomes a GitHub Actions secret:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;gh&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; secret&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; set&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; GARMINTOKENS_BASE64&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; --repo&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; snehankekre/snehankekre.github.io&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; &amp;#x3C;&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt; ~/.garminconnect.b64&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I had problems with the login. Garmin rate-limited my IP mid-attempt, even though I rotated through Mullvad’s servers. The library tries a
chain of login strategies, and the first two (&lt;code&gt;mobile+cffi&lt;/code&gt;, then &lt;code&gt;mobile+requests&lt;/code&gt;) both
came back with a &lt;code&gt;429&lt;/code&gt; (“IP rate limited by Garmin”). Thankfully the library’s fallback strategy further down the chain succeeded.
It was a one-time toll either way: CI never logs in at all, it only replays tokens, so the login endpoint never sees it again.&lt;/p&gt;
&lt;h2 id=&quot;fetching-yesterday-every-morning&quot;&gt;Fetching yesterday, every morning&lt;/h2&gt;
&lt;p&gt;My site uses Astro, built and deployed to GitHub Pages by an Actions workflow. I figured if CI builds the site, CI can fetch data right before the
build, and the data would ship as a static file like everything else. So there’s a small Python
script, &lt;a href=&quot;https://github.com/snehankekre/snehankekre.github.io/blob/27b39defb12186efca3312041d83dd2e4e1d7c5f/scripts/fetch_hr.py&quot;&gt;&lt;code&gt;fetch_hr.py&lt;/code&gt;&lt;/a&gt;,
that runs before every build. The heart of it:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;tokens &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    os.environ.get(&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;GARMINTOKENS_BASE64&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;).strip()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    or&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; os.environ.get(&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;GARMINTOKENS&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;~/.garminconnect&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;garmin &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; Garmin()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;garmin.login(tokens)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;tz &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ZoneInfo(os.environ.get(&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;HR_TZ&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;Asia/Makassar&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;target &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (datetime.now(tz) &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; timedelta(&lt;/span&gt;&lt;span style=&quot;color:#E36209;--shiki-dark:#FFAB70&quot;&gt;days&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)).date()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;data &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; garmin.get_heart_rates(target.isoformat())&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;points &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;    [ts, bpm]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    for&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; ts, bpm &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;in&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (data.get(&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;heartRateValues&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;or&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; [])&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; bpm &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;is&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; not&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; None&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;]&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;garminconnect&lt;/code&gt; 0.3.x accepts either a token directory or the base64 blob in the same &lt;code&gt;login()&lt;/code&gt;
call: any string longer than 512 characters is treated as a blob, anything shorter as a path.
Which means the identical script runs on my Mac (reading &lt;code&gt;~/.garminconnect&lt;/code&gt;) and in CI
(reading the secret), with no branching on my side.&lt;/p&gt;
&lt;p&gt;The timezone part was a little tricky for me to figure out. It would have silently served wrong data. GitHub runners
live in UTC. My 3 a.m. Bali cron fires at 19:00 UTC, which is still the &lt;em&gt;previous&lt;/em&gt; day in
UTC. So on Wednesday 3 a.m. my time, a naive &lt;code&gt;date.today() - timedelta(days=1)&lt;/code&gt; on the runner
computes Monday, and I’d be replaying a two-day-old heart. “Yesterday” has to be computed in
the timezone where my wrist lives, hence &lt;code&gt;HR_TZ=Asia/Makassar&lt;/code&gt;, passed in from the workflow
so it’s a one-line change next time I move.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;if bpm is not None&lt;/code&gt; filter matters too because whenever the watch is off my wrist, Garmin
records nulls rather than nothing, and I want holes in the data to &lt;em&gt;look like holes&lt;/em&gt; (more on
that below). What survives gets written to &lt;code&gt;public/hr.json&lt;/code&gt;, about 14 KB:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;{&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;&quot;date&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;2026-07-13&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;&quot;resting&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;48&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;&quot;points&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;:[[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1783872000000&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;56&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;],[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1783872120000&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;56&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;],&lt;/span&gt;&lt;span style=&quot;color:#B31D28;--shiki-light-font-style:italic;--shiki-dark:#FDAEB7;--shiki-dark-font-style:italic&quot;&gt;...&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;]}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Roughly 717 samples, one every ~2 minutes, timestamps in absolute epoch milliseconds GMT.
That absoluteness matters later on.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A note on that 2 minute cadence, because it confused me:&lt;/strong&gt; it is not how often the watch
measures. The optical sensor &lt;a href=&quot;https://www.garmin.com/en-US/blog/health/garmin-smartwatches-measure-heart-rate-every-second/&quot;&gt;samples multiple times per second, all day&lt;/a&gt;,
and Garmin’s official &lt;a href=&quot;https://developer.garmin.com/gc-developer-program/health-api/&quot;&gt;Health API&lt;/a&gt;
(the gated one) serves all-day heart rate as 15 second representative samples. The 2 minute
series is what the Connect app’s daily chart endpoint aggregates down to, and since that is
the endpoint &lt;code&gt;python-garminconnect&lt;/code&gt; reads, that is what I get. If I ever want finer than
2 minute resolution without Health API access, there is a path I may look into: recorded
activities (dives, gym sessions) are stored as FIT files with 1 second samples, and
&lt;code&gt;python-garminconnect&lt;/code&gt; can download those too. But for now 2 minutes is plenty for a heartbeat widget.&lt;/p&gt;
&lt;p&gt;The other rule I gave the script: it is not allowed to fail. The entire body runs inside one
try/except that ends like this:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;except&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; Exception&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; as&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; e:  &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;# never break the build&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;    print&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;f&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;hr fetch skipped: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;{type&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(e).&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;__name__}&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;{&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;e&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;}&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&quot;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 0&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the watch didn’t sync, tokens expired, or Garmin is down, it logs the reason, exits 0, and writes
nothing. The build proceeds, the widget finds no data and hides.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/snehankekre/snehankekre.github.io/blob/27b39defb12186efca3312041d83dd2e4e1d7c5f/.github/workflows/deploy.yml#L7-L11&quot;&gt;workflow side&lt;/a&gt;
is two cron triggers and one step in front of the Astro build:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;schedule&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;  # 03:00 in Bali, once the day is complete,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;  # and 12:00 to catch a late phone sync.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  - &lt;/span&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;cron&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&apos;0 19 * * *&apos;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  - &lt;/span&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;cron&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&apos;0 4 * * *&apos;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;- &lt;/span&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;Fetch heart-rate data&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;  env&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;    GARMINTOKENS_BASE64&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;${{ secrets.GARMINTOKENS_BASE64 }}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;    HR_TZ&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;Asia/Makassar&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#22863A;--shiki-dark:#85E89D&quot;&gt;  run&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;|&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;    pip install --quiet garminconnect==0.3.6&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;    python scripts/fetch_hr.py&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The version is pinned to &lt;code&gt;0.3.6&lt;/code&gt; because this is an unofficial client of a private API: I
validated the token handling and response shapes against that exact version, and I’d rather
a future breaking change fail loudly than drift silently. The JSON lands in
&lt;code&gt;public/&lt;/code&gt;, Astro copies &lt;code&gt;public/&lt;/code&gt; verbatim into the build, and GitHub Pages serves it like
any other file. I don’t have to commit anything: &lt;code&gt;public/hr.json&lt;/code&gt; is gitignored, so the repo
doesn’t accumulate a year of heartbeat diffs, and the data lives only inside the deployed
artifact.&lt;/p&gt;
&lt;h2 id=&quot;replaying-it-exactly-24-hours-later&quot;&gt;Replaying it exactly 24 hours later&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/snehankekre/snehankekre.github.io/blob/27b39defb12186efca3312041d83dd2e4e1d7c5f/src/pages/index.astro#L520-L570&quot;&gt;client side&lt;/a&gt;
is maybe 40 lines of vanilla JS inlined in the page. The browser fetches &lt;code&gt;/hr.json&lt;/code&gt; and,
every 10 seconds, evaluates a single target instant:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; bpm&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; sample&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(Date.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;now&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;() &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 86_400_000&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That subtraction is why the epoch-milliseconds detail from earlier mattered.
Garmin’s timestamps and &lt;code&gt;Date.now()&lt;/code&gt; are both absolute GMT milliseconds, so a
visitor in Tokyo and a visitor in New York ask the same question, “what was his heart doing
exactly 24 hours ago”, and get the same correct answer.&lt;/p&gt;
&lt;p&gt;Here’s &lt;code&gt;sample()&lt;/code&gt; in full (the repo version has TypeScript annotations, trimmed here):&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; first&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; pts[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;][&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; last&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; pts[pts.&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;length&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;][&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; DAY&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 86_400_000&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; GAP&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 20&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 60&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; *&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 1000&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;// holes wider than this are &quot;no signal&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; sample&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#E36209;--shiki-dark:#FFAB70&quot;&gt;t&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&gt;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (t &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;&gt;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; last) t &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; DAY&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;// just past the data day (pre-refresh); replay it&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (t &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;&amp;#x3C;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; first &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; GAP&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; ||&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; t &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;&gt;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; last &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; GAP&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  let&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; prev &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  let&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; next &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  for&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; p&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; of&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; pts) {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (p[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;&amp;#x3C;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; t) prev &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; p;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;    else&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; { next &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; p; &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;break&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;prev) &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; next &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; next[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; t &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;&amp;#x3C;=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; GAP&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; ?&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; next[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;!&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;next &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;||&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; next[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; prev[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;&gt;&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; GAP&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; t &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; prev[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;&amp;#x3C;=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; GAP&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; ?&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; prev[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; null&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  const&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; f&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (t &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; prev[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;]) &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (next[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; prev[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;]);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;  return&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; Math.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;round&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(prev[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; f &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (next[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; prev[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;]));&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;};&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Yeah, I know that’s a O(n) linear scan to find the bracketing pair. It’s 717 points once every 10
seconds. But a binary search here would be cleverness with no payoff.&lt;/p&gt;
&lt;p&gt;Garmin samples every ~2 minutes, and a number that changes once per 2 minutes reads as dead. It would be
boring from a visitor’s perspective, and it would be boring from my perspective too: I want to see the
widget move. So between the two real samples bracketing the target instant, the
widget reads off the straight line connecting them. Say the target &lt;code&gt;t&lt;/code&gt; falls at 10:00:50,
between a sample of 62 bpm at 10:00:00 and one of 68 bpm at 10:02:00:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;68 ┤                      ● next&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;   │          ◌ ← reading at t&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;62 ┤ ● prev&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;   └─┬────────┬───────────┬──&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;   10:00  10:00:50      10:02&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The fraction of the way across is &lt;code&gt;f = 50/120 ≈ 0.417&lt;/code&gt;, and the reading is
&lt;code&gt;62 + 0.417 × (68 − 62) = 64.5&lt;/code&gt;, rounded to 65:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; f&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (t &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; prev[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;]) &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;/&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (next[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; prev[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;]);   &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;// 0 at prev, 1 at next&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;return&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; Math.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;round&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(prev[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; f &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (next[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;] &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; prev[&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;]));&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ticking every 10 seconds walks the reading along that chord: 62, 63, 63, 64, 65.
Every displayed value lies on a straight line between two real
measurements, which is what I gathered Garmin app’s own daily graph draws.s&lt;/p&gt;
&lt;p&gt;But interpolation has a hidden assumption that the bracketing samples are adjacent, two
minutes apart. The data has real holes because sometimes the watch is on a charger or on my sink
while I shower, and Garmin records nulls instead of nothing. Suppose
it was off my wrist from 14:00 to 17:00. The naive code would take 71 bpm at 14:00 and 58 bpm
at 17:00 and happily “interpolate” three hours of smooth, plausible, entirely invented
readings between them. The guard is a threshold I set at 20 minutes. So if the two samples
around the target are further apart than that, the widget refuses to interpolate and hides
instead. Twenty minutes is comfortably wider than the data’s normal cadence. I checked a full
day of my own data: 715 of 716 consecutive samples were exactly 2 minutes apart, through the
night included, and the one exception was an 8 minute gap at 09:20 that was almost certainly
my shower. So ordinary data never trips the guard, and it’s narrow enough that a real gap
hides the widget quickly rather than presenting a three-hour-old number as current.&lt;/p&gt;
&lt;p&gt;The last problem is midnight. The file covers one Bali calendar day and refreshes at 3 a.m.,
so between midnight and the refresh, &lt;code&gt;now − 24h&lt;/code&gt; points past the end of the data, and the
widget would vanish every single night. It ends up being a one-line fix in the same place as the staleness guard:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; (t &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;&gt;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; last) t &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;-=&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; DAY&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;  &lt;/span&gt;&lt;span style=&quot;color:#6A737D;--shiki-dark:#6A737D&quot;&gt;// just past the data? replay the same time, one day earlier&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A visitor at 1:30 a.m. briefly gets 1:30 a.m. from two days ago instead of nothing, which for
a widget whose entire premise is a replay seems like a fair trade. The part I like is that
the same line is the staleness guard, because it rewinds at most once. If the pipeline dies
and the file goes stale for days, even the rewound target misses the data and the widget
hides. The wraparound grants at most 24 hours of grace and can never dress up week-old data
as current.&lt;/p&gt;
&lt;p&gt;I decided to hide the widget on failure rather than show a stale number. The widget’s markup ships with the
&lt;code&gt;hidden&lt;/code&gt; attribute already on it, and only a successful fetch plus a valid sample removes it.
Failed fetch, empty file, stale data, hole in the samples all result in the widget staying hidden.&lt;/p&gt;
&lt;h2 id=&quot;making-it-beat&quot;&gt;Making it beat&lt;/h2&gt;
&lt;p&gt;The pulse is a CSS animation that scales the heart glyph up and down at the same frequency as the data.
Every tick, the script writes the reading into the DOM and sets a CSS custom property to the beat interval, &lt;code&gt;60/bpm&lt;/code&gt; seconds:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;js&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;bpmEl.textContent &lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt; String&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(bpm);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;heartEl.style.&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;setProperty&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;&apos;--beat&apos;&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;`${&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;60&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt; /&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; bpm&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;).&lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;toFixed&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;3&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;color:#032F62;--shiki-dark:#9ECBFF&quot;&gt;}s`&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;a href=&quot;https://github.com/snehankekre/snehankekre.github.io/blob/27b39defb12186efca3312041d83dd2e4e1d7c5f/src/pages/index.astro#L685-L701&quot;&gt;animation&lt;/a&gt;
just reads that variable, so the heart on the page beats at whatever rate the data says.
48 bpm visibly beats slower than 80:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light github-dark&quot; style=&quot;background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;css&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;.hr-heart&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  display&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;inline-block&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  color&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;var&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E36209;--shiki-dark:#FFAB70&quot;&gt;--accent&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  transform-origin&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;50&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;%&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; 60&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;%&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;  animation&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: hr-beat &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;var&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#E36209;--shiki-dark:#FFAB70&quot;&gt;--beat&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;s&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;ease-out&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt; infinite&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#D73A49;--shiki-dark:#F97583&quot;&gt;@keyframes&lt;/span&gt;&lt;span style=&quot;color:#E36209;--shiki-dark:#FFAB70&quot;&gt; hr-beat&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;  0%&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;        { &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;transform&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;scale&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;); }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;  14%&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;       { &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;transform&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;scale&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1.32&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;); }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;  28%&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;       { &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;transform&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;scale&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;); }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;  42%&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;       { &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;transform&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;scale&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1.18&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;); }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;  56%&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#6F42C1;--shiki-dark:#B392F0&quot;&gt;100%&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt; { &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;transform&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;scale&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#005CC5;--shiki-dark:#79B8FF&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;); }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#24292E;--shiki-dark:#E1E4E8&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The keyframes mirror the &lt;a href=&quot;https://en.wikipedia.org/wiki/Cardiac_cycle&quot;&gt;systolic/diastolic cycle&lt;/a&gt;
where you see a big bump, then a smaller one, then rest for the remainder of
the cycle, because a single symmetric scale pulse looked like a notification badge rather
than a heart. &lt;code&gt;transform-origin: 50% 60%&lt;/code&gt; makes the glyph swell from its visual center
instead of its bounding-box center, which for ♥ are not the same place. And
&lt;code&gt;prefers-reduced-motion&lt;/code&gt; disables the animation entirely; the number still updates for anyone
who has asked their OS for less motion.&lt;/p&gt;
&lt;h2 id=&quot;what-does-this-cost-me-nothing-really&quot;&gt;What does this cost me? Nothing, really.&lt;/h2&gt;
&lt;p&gt;There’s no server (besides GitHub Pages), no third-party relay, and no hit to the watch battery, since it syncs
exactly like it did before. I wear the watch, it syncs to my phone, and the phone syncs to Garmin’s servers. The added
difference is that CI fetches the data from Garmin’s servers once a day and ships it to the site.
The whole thing is one Python script, two cron lines, and some JS. The only upkeep I expect is the
tokens expiring in about a year, at which point the heart will quietly vanish from my homepage and I’ll rerun the login script.
I’ve asked Google Assistant to remind me in 11 months :D.&lt;/p&gt;
&lt;p&gt;Someday, when I get around to it, I’ll add the workouts and the rest of the ledger. The same
client that fetches my heart rate can fetch every gym and streetlifting session I’ve ever
logged, so most of the plumbing here carries over. And because activities come down as FIT
files with 1 second samples, the workouts section could be far more granular than the all-day
series ever was. Until then, you get my heartbeat exactly 24 hours late.&lt;/p&gt;</content:encoded></item></channel></rss>