bottomtime 0.2.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bottomtime-0.2.0 → bottomtime-0.4.0}/.gitignore +1 -0
- {bottomtime-0.2.0 → bottomtime-0.4.0}/PKG-INFO +2 -1
- {bottomtime-0.2.0 → bottomtime-0.4.0}/pyproject.toml +7 -2
- {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/__init__.py +1 -1
- {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/api.py +1 -1
- {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/ingest/shearwater_db.py +20 -9
- {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/store/schema.py +8 -4
- {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/verify/crosscheck.py +5 -0
- bottomtime-0.4.0/tests/test_decoder_dependency.py +40 -0
- bottomtime-0.2.0/.claude/settings.local.json +0 -30
- bottomtime-0.2.0/.github/workflows/test.yml +0 -20
- bottomtime-0.2.0/src/bottomtime/decode/pnf.py +0 -452
- bottomtime-0.2.0/src/bottomtime/verify/__init__.py +0 -1
- bottomtime-0.2.0/tests/test_pnf.py +0 -148
- {bottomtime-0.2.0 → bottomtime-0.4.0}/LICENSE +0 -0
- {bottomtime-0.2.0 → bottomtime-0.4.0}/README.md +0 -0
- {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/cli.py +0 -0
- {bottomtime-0.2.0/src/bottomtime/decode → bottomtime-0.4.0/src/bottomtime/ingest}/__init__.py +0 -0
- {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/ingest/archive.py +0 -0
- {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/ingest/garmin_fit.py +0 -0
- {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/ingest/shearwater_xml.py +0 -0
- {bottomtime-0.2.0/src/bottomtime/ingest → bottomtime-0.4.0/src/bottomtime/match}/__init__.py +0 -0
- {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/match/correlate.py +0 -0
- {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/match/matcher.py +0 -0
- {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/plot.py +0 -0
- {bottomtime-0.2.0/src/bottomtime/match → bottomtime-0.4.0/src/bottomtime/store}/__init__.py +0 -0
- {bottomtime-0.2.0 → bottomtime-0.4.0}/src/bottomtime/store/db.py +0 -0
- {bottomtime-0.2.0/src/bottomtime/store → bottomtime-0.4.0/src/bottomtime/verify}/__init__.py +0 -0
- {bottomtime-0.2.0 → bottomtime-0.4.0}/tests/test_cli.py +0 -0
- {bottomtime-0.2.0 → bottomtime-0.4.0}/tests/test_correlate.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bottomtime
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Lossless dive-log store: ingest, decode, and reconcile dives from Garmin (FIT) and Shearwater (Petrel Native Format) dive computers
|
|
5
5
|
Project-URL: Homepage, https://github.com/snehankekre/bottomtime
|
|
6
6
|
Project-URL: Issues, https://github.com/snehankekre/bottomtime/issues
|
|
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
14
14
|
Classifier: Topic :: Scientific/Engineering
|
|
15
15
|
Requires-Python: >=3.10
|
|
16
16
|
Requires-Dist: garmin-fit-sdk>=21.0
|
|
17
|
+
Requires-Dist: pnf>=0.1.0
|
|
17
18
|
Provides-Extra: dev
|
|
18
19
|
Requires-Dist: pytest; extra == 'dev'
|
|
19
20
|
Provides-Extra: plot
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bottomtime"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Lossless dive-log store: ingest, decode, and reconcile dives from Garmin (FIT) and Shearwater (Petrel Native Format) dive computers"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -18,7 +18,7 @@ classifiers = [
|
|
|
18
18
|
"Programming Language :: Python :: 3",
|
|
19
19
|
"Topic :: Scientific/Engineering",
|
|
20
20
|
]
|
|
21
|
-
dependencies = ["garmin-fit-sdk>=21.0"]
|
|
21
|
+
dependencies = ["garmin-fit-sdk>=21.0", "pnf>=0.1.0"]
|
|
22
22
|
|
|
23
23
|
[project.optional-dependencies]
|
|
24
24
|
plot = ["matplotlib>=3.5"]
|
|
@@ -33,3 +33,8 @@ Issues = "https://github.com/snehankekre/bottomtime/issues"
|
|
|
33
33
|
|
|
34
34
|
[tool.hatch.build.targets.wheel]
|
|
35
35
|
packages = ["src/bottomtime"]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.sdist]
|
|
38
|
+
# Positive allowlist: the sdist ships only the package, its tests, and the
|
|
39
|
+
# standard metadata files, so local tooling and CI config stay out of releases.
|
|
40
|
+
include = ["/src", "/tests", "/README.md", "/LICENSE"]
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"""Ingest a Shearwater Cloud database (dive_data.db / 'Export Database').
|
|
2
2
|
|
|
3
3
|
Each dive's complete native log lives in log_data.data_bytes_1 as an sw-pnf
|
|
4
|
-
blob (decoded by
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
blob (decoded by the pnf package); dive_details supplies the metadata layer
|
|
5
|
+
(gas plan JSON, EndGF99, site/notes when present). The blob is stored verbatim
|
|
6
|
+
in source_dives.raw_blob in addition to the archived .db, so every Shearwater
|
|
7
|
+
byte is preserved twice over.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
@@ -15,10 +15,18 @@ import sqlite3
|
|
|
15
15
|
from datetime import datetime, timezone
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
import pnf
|
|
19
|
+
|
|
19
20
|
from ..store.db import source_dive_exists
|
|
20
21
|
from .archive import archive_file
|
|
21
22
|
|
|
23
|
+
# Decode-contract version stamped on each Shearwater source dive so that an
|
|
24
|
+
# archive-first re-decode can find dives produced by an older decode. Bump this
|
|
25
|
+
# whenever a pnf upgrade changes decoded output. History: 1 libdivecomputer
|
|
26
|
+
# field set, 2 empirical ceiling/gf99/battery/@+5, 3 pnf reconciliation
|
|
27
|
+
# (DCIEM branch, 16-bit battery, battery %, SAC, status bits).
|
|
28
|
+
DECODER_VERSION = 3
|
|
29
|
+
|
|
22
30
|
|
|
23
31
|
def _details_row(con: sqlite3.Connection, dive_id: str) -> dict:
|
|
24
32
|
row = con.execute(
|
|
@@ -123,7 +131,7 @@ def _ingest_log(
|
|
|
123
131
|
artifact_id,
|
|
124
132
|
source_key,
|
|
125
133
|
digest,
|
|
126
|
-
|
|
134
|
+
DECODER_VERSION,
|
|
127
135
|
start_local,
|
|
128
136
|
dive.header.get("divetime_s"),
|
|
129
137
|
dive.header.get("max_depth_m"),
|
|
@@ -139,19 +147,22 @@ def _ingest_log(
|
|
|
139
147
|
con.executemany(
|
|
140
148
|
"INSERT INTO shearwater_samples (source_dive_id, t_s, depth_m, temp_c,"
|
|
141
149
|
" stop_depth_m, stop_or_ndl_min, tts_min, in_deco, avg_ppo2, o2_pct, he_pct,"
|
|
142
|
-
" setpoint, cns_pct, gf99, ceiling_m,
|
|
150
|
+
" setpoint, cns_pct, gf99, ceiling_m, safe_ascent_depth_m, battery_pct, sac,"
|
|
151
|
+
" sensor1_raw, sensor2_raw, sensor3_raw,"
|
|
143
152
|
" sensor1_ppo2, sensor2_ppo2, sensor3_ppo2, battery_v, status_flags,"
|
|
144
|
-
" tank0_psi, tank1_psi, gas_time_min, extra_json)"
|
|
145
|
-
" VALUES (
|
|
153
|
+
" solenoid_fired_count, tank0_psi, tank1_psi, gas_time_min, extra_json)"
|
|
154
|
+
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
146
155
|
[
|
|
147
156
|
(
|
|
148
157
|
dive_id, s.t_s, s.depth_m, s.temp_c,
|
|
149
158
|
s.stop_depth_m, s.stop_or_ndl_min, s.tts_min, s.in_deco,
|
|
150
159
|
s.avg_ppo2, s.o2_pct, s.he_pct,
|
|
151
160
|
s.setpoint, s.cns_pct, s.gf99, s.ceiling_m,
|
|
161
|
+
s.safe_ascent_depth_m, s.battery_pct, s.sac,
|
|
152
162
|
s.sensor1_raw, s.sensor2_raw, s.sensor3_raw,
|
|
153
163
|
s.sensor1_ppo2, s.sensor2_ppo2, s.sensor3_ppo2,
|
|
154
164
|
s.battery_v, s.status_flags,
|
|
165
|
+
s.solenoid_fired_count,
|
|
155
166
|
s.tank0_psi, s.tank1_psi, s.gas_time_min,
|
|
156
167
|
json.dumps(s.extra, separators=(",", ":")) if s.extra else None,
|
|
157
168
|
)
|
|
@@ -11,7 +11,7 @@ Design principles:
|
|
|
11
11
|
dives is the canonical entity built from matches + singletons.
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
SCHEMA_VERSION =
|
|
14
|
+
SCHEMA_VERSION = 2
|
|
15
15
|
|
|
16
16
|
DDL = """
|
|
17
17
|
CREATE TABLE IF NOT EXISTS meta (
|
|
@@ -89,8 +89,11 @@ CREATE TABLE IF NOT EXISTS shearwater_samples (
|
|
|
89
89
|
he_pct REAL,
|
|
90
90
|
setpoint REAL,
|
|
91
91
|
cns_pct REAL,
|
|
92
|
-
gf99 REAL, -- populated at decoder_version >= 2
|
|
93
|
-
ceiling_m REAL, -- populated at decoder_version >= 2
|
|
92
|
+
gf99 REAL, -- populated at decoder_version >= 2; NULL under DCIEM
|
|
93
|
+
ceiling_m REAL, -- populated at decoder_version >= 2; NULL under DCIEM
|
|
94
|
+
safe_ascent_depth_m REAL, -- DCIEM only (bytes 24-25); NULL otherwise, v3
|
|
95
|
+
battery_pct INTEGER, -- battery percent remaining (byte 11), v3
|
|
96
|
+
sac REAL, -- surface air consumption (bytes 30-31), v3
|
|
94
97
|
sensor1_raw INTEGER,
|
|
95
98
|
sensor2_raw INTEGER,
|
|
96
99
|
sensor3_raw INTEGER,
|
|
@@ -98,7 +101,8 @@ CREATE TABLE IF NOT EXISTS shearwater_samples (
|
|
|
98
101
|
sensor2_ppo2 REAL,
|
|
99
102
|
sensor3_ppo2 REAL,
|
|
100
103
|
battery_v REAL,
|
|
101
|
-
status_flags INTEGER,
|
|
104
|
+
status_flags INTEGER, -- raw sample byte 12 (packed mode/status bits)
|
|
105
|
+
solenoid_fired_count INTEGER, -- CCR O2 solenoid firings, byte 12 bits 6-7, v3
|
|
102
106
|
tank0_psi REAL,
|
|
103
107
|
tank1_psi REAL,
|
|
104
108
|
gas_time_min REAL,
|
|
@@ -30,6 +30,7 @@ TOL = {
|
|
|
30
30
|
"temp_c": 0.51,
|
|
31
31
|
"battery_v": 0.011,
|
|
32
32
|
"ndl_min": 0.001,
|
|
33
|
+
"sac": 0.011,
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
|
|
@@ -71,6 +72,10 @@ def check_xml(con: sqlite3.Connection, xml_dir: Path, failures: list) -> None:
|
|
|
71
72
|
("averagePPO2", "avg_ppo2", 1.0),
|
|
72
73
|
("waterTemp", "temp_c", 1.0),
|
|
73
74
|
("batteryVoltage", "battery_v", 1.0),
|
|
75
|
+
# SAC (bytes 30-31) only present with wireless air integration. The
|
|
76
|
+
# XML is produced by the same parser, so it should match exactly;
|
|
77
|
+
# inert on dives without AI (both sides None).
|
|
78
|
+
("sac", "sac", 1.0),
|
|
74
79
|
]
|
|
75
80
|
for i, (db_row, x) in enumerate(zip(samples, xs)):
|
|
76
81
|
for xml_field, col, scale in pairs:
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Contract test for the pnf decoder dependency.
|
|
2
|
+
|
|
3
|
+
The PNF decoder lives in the standalone `pnf` package (with its own full test
|
|
4
|
+
suite). bottomtime depends on it and relies on a specific surface: the
|
|
5
|
+
`decode` entry point, the `COMPUTER_MODELS` table, and a set of sample fields
|
|
6
|
+
the Shearwater ingest inserts into `shearwater_samples`. This test pins that
|
|
7
|
+
contract so a breaking pnf upgrade fails here rather than at ingest time.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import pnf
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_decode_entry_points_exist():
|
|
14
|
+
assert callable(pnf.decode)
|
|
15
|
+
assert callable(pnf.decompress)
|
|
16
|
+
assert 11 in pnf.COMPUTER_MODELS # Perdix 2, surfaced by api.computer_info
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_sample_has_fields_ingest_inserts():
|
|
20
|
+
# Every column bottomtime writes to shearwater_samples from a decoded
|
|
21
|
+
# sample (see ingest/shearwater_db.py).
|
|
22
|
+
fields = pnf.Sample.__dataclass_fields__
|
|
23
|
+
for name in (
|
|
24
|
+
"t_s", "depth_m", "temp_c", "stop_depth_m", "stop_or_ndl_min", "tts_min",
|
|
25
|
+
"in_deco", "avg_ppo2", "o2_pct", "he_pct", "setpoint", "cns_pct",
|
|
26
|
+
"gf99", "ceiling_m", "safe_ascent_depth_m", "battery_pct", "sac",
|
|
27
|
+
"sensor1_raw", "sensor2_raw", "sensor3_raw",
|
|
28
|
+
"sensor1_ppo2", "sensor2_ppo2", "sensor3_ppo2",
|
|
29
|
+
"battery_v", "status_flags", "solenoid_fired_count",
|
|
30
|
+
"tank0_psi", "tank1_psi", "gas_time_min", "extra",
|
|
31
|
+
):
|
|
32
|
+
assert name in fields, f"pnf.Sample lost field {name!r}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_dive_shape_ingest_reads():
|
|
36
|
+
for attr in ("header", "samples", "gases", "events", "unknown_records",
|
|
37
|
+
"sample_interval_ms"):
|
|
38
|
+
assert attr in pnf.Dive.__dataclass_fields__, f"pnf.Dive lost {attr!r}"
|
|
39
|
+
for attr in ("index", "o2_pct", "he_pct", "diluent", "enabled"):
|
|
40
|
+
assert attr in pnf.Gas.__dataclass_fields__, f"pnf.Gas lost {attr!r}"
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"Bash(sqlite3 \"file:data/dives.db?mode=ro\" \"SELECT source, count(*) FROM source_dives GROUP BY source;\" \"SELECT json_extract(header_json,'$.pnf.computer_model'), json_extract(header_json,'$.pnf.computer_firmware'), json_extract(header_json,'$.dive_details.Product'), count(*) FROM source_dives WHERE source='shearwater' GROUP BY 1,2,3;\")",
|
|
5
|
-
"Bash(sqlite3 \"file:data/dives.db?mode=ro\" \"SELECT json_extract(header_json,'$.file_id_mesgs[0].product_name'), json_extract(header_json,'$.file_id_mesgs[0].manufacturer'), json_extract(header_json,'$.file_id_mesgs[0].product'), count(*) FROM source_dives WHERE source='garmin' GROUP BY 1,2,3 LIMIT 10;\")",
|
|
6
|
-
"Bash(sqlite3 \"file:$(pwd)/data/dives.db?mode=ro&immutable=1\" \"SELECT source, count(*) FROM source_dives GROUP BY source;\")",
|
|
7
|
-
"Bash(DB=\"file:$(pwd)/data/dives.db?mode=ro&immutable=1\")",
|
|
8
|
-
"Bash(sqlite3 \"$DB\" \"SELECT json_extract(header_json,''$.pnf.computer_model''), json_extract(header_json,''$.pnf.computer_firmware''), json_extract(header_json,''$.dive_details.Product''), count(*) FROM source_dives WHERE source=''shearwater'' GROUP BY 1,2,3;\")",
|
|
9
|
-
"Bash(sqlite3 \"$DB\" \"SELECT json_extract(header_json,''$.file_id_mesgs[0].product_name''), json_extract(header_json,''$.device_info_mesgs[0].product_name''), json_extract(header_json,''$.device_info_mesgs[0].software_version''), count(*) FROM source_dives WHERE source=''garmin'' GROUP BY 1,2,3 LIMIT 10;\")",
|
|
10
|
-
"Bash(DB=\"file:$(pwd)/data/dives.db?mode=ro&immutable=1\")",
|
|
11
|
-
"Bash(sqlite3 \"$DB\" \"SELECT json_extract(header_json,''$.file_id_mesgs[0]'') FROM source_dives WHERE source=''garmin'' LIMIT 1;\")",
|
|
12
|
-
"Bash(sqlite3 \"$DB\" \"SELECT json_extract(header_json,''$.device_info_mesgs[0]'') FROM source_dives WHERE source=''garmin'' LIMIT 1;\")",
|
|
13
|
-
"Bash(sqlite3 \"$DB\" \"SELECT DISTINCT json_extract(header_json,''$.file_id_mesgs[0].garmin_product'') FROM source_dives WHERE source=''garmin'';\")",
|
|
14
|
-
"Bash(TMP=/var/folders/xx/8wmmss0x7lb3v3mrhc1rzzc00000gn/T/tmp.yC0jxrHEY0)",
|
|
15
|
-
"Bash(python -c \"import matplotlib; print(matplotlib.__version__)\")",
|
|
16
|
-
"Bash(python -m bottomtime.cli --db \"$TMP/dives.db\" plot 318 -o \"$TMP/d318.png\")",
|
|
17
|
-
"Bash(python -c \"\nfrom bottomtime.api import load_dive\nfrom bottomtime.plot import aligned_series\nd = load_dive(''$TMP/dives.db'', 318)\nfor s in aligned_series(d):\n print(s[''source''], s[''label''], ''x0=%.0f'' % s[''x''][0], ''xN=%.0f'' % s[''x''][-1])\n\")",
|
|
18
|
-
"Bash(python -m pytest tests/ -q 2 >& 1)",
|
|
19
|
-
"Bash(python -c \"import sys; print(sys.prefix != sys.base_prefix, sys.prefix)\")",
|
|
20
|
-
"Bash(rg -n \"—|–\" --no-heading /Users/snehan/Downloads/bottomtime/README.md /Users/snehan/Downloads/bottomtime/src /Users/snehan/Downloads/bottomtime/tests /Users/snehan/Downloads/bottomtime/pyproject.toml)",
|
|
21
|
-
"Bash(rg -ni \"leverage|seamless|robust|delve|deep dive|journey|landscape|ecosystem|empower|unlock|supercharge|elevate|streamline|game-chang|cutting-edge|state-of-the-art|best-in-class|thrilled|excited|passionate|humbled|honored|compelling|actionable|utilize|in order to|testament|underscores|boasts|not just|isn''t about|not about\" --no-heading /Users/snehan/Downloads/bottomtime/README.md /Users/snehan/Downloads/bottomtime/src /Users/snehan/Downloads/bottomtime/tests)",
|
|
22
|
-
"Bash(ls -R /Users/snehan/Downloads/snehankekre.github.io/.claude 2 > /dev/null)",
|
|
23
|
-
"Bash(ls /Users/snehan/.claude/projects/-Users-snehan-Downloads-snehankekre-github-io/memory/ 2>/dev/null)",
|
|
24
|
-
"Bash(rg -n \"—|–\" /Users/snehan/Downloads/bottomtime/README.md /Users/snehan/Downloads/bottomtime/src /Users/snehan/Downloads/bottomtime/tests /Users/snehan/Downloads/bottomtime/pyproject.toml)",
|
|
25
|
-
"Bash(echo \"clean: $?\")",
|
|
26
|
-
"Bash(python -m pytest tests/ -q)"
|
|
27
|
-
],
|
|
28
|
-
"deny": []
|
|
29
|
-
}
|
|
30
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
name: test
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches: [main]
|
|
6
|
-
pull_request:
|
|
7
|
-
|
|
8
|
-
jobs:
|
|
9
|
-
test:
|
|
10
|
-
runs-on: ubuntu-latest
|
|
11
|
-
strategy:
|
|
12
|
-
matrix:
|
|
13
|
-
python-version: ["3.10", "3.14"]
|
|
14
|
-
steps:
|
|
15
|
-
- uses: actions/checkout@v4
|
|
16
|
-
- uses: actions/setup-python@v5
|
|
17
|
-
with:
|
|
18
|
-
python-version: ${{ matrix.python-version }}
|
|
19
|
-
- run: pip install -e .[dev]
|
|
20
|
-
- run: pytest -q
|
|
@@ -1,452 +0,0 @@
|
|
|
1
|
-
"""Pure-Python decoder for Shearwater's Petrel Native Format (PNF).
|
|
2
|
-
|
|
3
|
-
Shearwater Cloud stores each dive as a `sw-pnf` blob: a 4-byte little-endian
|
|
4
|
-
uncompressed-length prefix followed by a gzip stream. The decompressed payload
|
|
5
|
-
is a sequence of 32-byte records; byte 0 of each record is its type:
|
|
6
|
-
|
|
7
|
-
0x01 dive sample 0x10-0x19 opening (config) records
|
|
8
|
-
0x02 freedive sample (4x8) 0x20-0x29 closing records
|
|
9
|
-
0x03 Avelo sample 0x30 info event
|
|
10
|
-
0xE1 extended dive sample 0xFF final record
|
|
11
|
-
|
|
12
|
-
Byte offsets follow libdivecomputer's shearwater_predator_parser.c: sample
|
|
13
|
-
fields sit at legacy-offset + 1 (the PNF type byte), opening/closing record
|
|
14
|
-
fields at the absolute offsets used for their legacy 128-byte block.
|
|
15
|
-
|
|
16
|
-
Record types this decoder does not understand are returned raw in
|
|
17
|
-
`PnfDive.unknown_records`; nothing is dropped.
|
|
18
|
-
|
|
19
|
-
DECODER_VERSION history:
|
|
20
|
-
1 initial port of the libdivecomputer field set
|
|
21
|
-
2 adds ceiling_m (byte 24), gf99 (byte 25), battery_v (byte 18 / 100) and
|
|
22
|
-
@+5 TTS (bytes 26-27), identified empirically: byte 25 converges on
|
|
23
|
-
dive_details.EndGF99 and decays during surface offgassing; byte 24 sits
|
|
24
|
-
just below the 3 m-quantized stop depth and zeroes when deco clears;
|
|
25
|
-
byte 18 equals the XML export's batteryVoltage x 100
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
from __future__ import annotations
|
|
29
|
-
|
|
30
|
-
import gzip
|
|
31
|
-
import struct
|
|
32
|
-
from dataclasses import dataclass, field
|
|
33
|
-
|
|
34
|
-
DECODER_VERSION = 2
|
|
35
|
-
|
|
36
|
-
RECORD_SIZE = 32
|
|
37
|
-
|
|
38
|
-
DIVE_SAMPLE = 0x01
|
|
39
|
-
FREEDIVE_SAMPLE = 0x02
|
|
40
|
-
AVELO_SAMPLE = 0x03
|
|
41
|
-
OPENING_0, OPENING_9 = 0x10, 0x19
|
|
42
|
-
CLOSING_0, CLOSING_9 = 0x20, 0x29
|
|
43
|
-
INFO_EVENT = 0x30
|
|
44
|
-
DIVE_SAMPLE_EXT = 0xE1
|
|
45
|
-
FINAL = 0xFF
|
|
46
|
-
|
|
47
|
-
# status flag bits (sample byte 12)
|
|
48
|
-
FLAG_GASSWITCH = 0x01
|
|
49
|
-
FLAG_PPO2_EXTERNAL = 0x02
|
|
50
|
-
FLAG_SETPOINT_HIGH = 0x04
|
|
51
|
-
FLAG_SC = 0x08
|
|
52
|
-
FLAG_OC = 0x10
|
|
53
|
-
|
|
54
|
-
DIVE_MODES = {
|
|
55
|
-
0: "cc",
|
|
56
|
-
1: "oc_tec",
|
|
57
|
-
2: "gauge",
|
|
58
|
-
3: "ppo2",
|
|
59
|
-
4: "sc",
|
|
60
|
-
5: "cc2",
|
|
61
|
-
6: "oc_rec",
|
|
62
|
-
7: "freedive",
|
|
63
|
-
12: "avelo",
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
DECO_MODELS = {0: "gf", 1: "vpmb", 2: "vpmb_gfs", 3: "dciem"}
|
|
67
|
-
|
|
68
|
-
# Final-record byte 13 model ids, names per libdivecomputer's descriptors.
|
|
69
|
-
# The PNF layout is shared across the family; bottomtime's field mappings are
|
|
70
|
-
# validated so far on Petrel 3 (10) and Perdix 2 (11) logs.
|
|
71
|
-
COMPUTER_MODELS = {
|
|
72
|
-
2: "Predator",
|
|
73
|
-
3: "Petrel",
|
|
74
|
-
4: "Nerd",
|
|
75
|
-
5: "Perdix",
|
|
76
|
-
6: "Perdix AI",
|
|
77
|
-
7: "Nerd 2",
|
|
78
|
-
8: "Teric",
|
|
79
|
-
9: "Peregrine",
|
|
80
|
-
10: "Petrel 3",
|
|
81
|
-
11: "Perdix 2",
|
|
82
|
-
12: "Tern",
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
# Sample bytes not (yet) assigned a meaning; kept per sample as b<offset>.
|
|
86
|
-
# 18 (battery), 24 (ceiling), 25 (gf99), 26-27 (@+5) were identified
|
|
87
|
-
# empirically and promoted in v2. 30-31 read 0xFFFF (sentinel) so far.
|
|
88
|
-
UNMAPPED_SAMPLE_BYTES = (11, 17, 30, 31)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def _u16be(data: bytes, off: int) -> int:
|
|
92
|
-
return struct.unpack_from(">H", data, off)[0]
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def _u24be(data: bytes, off: int) -> int:
|
|
96
|
-
return (data[off] << 16) | (data[off + 1] << 8) | data[off + 2]
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def _u32be(data: bytes, off: int) -> int:
|
|
100
|
-
return struct.unpack_from(">I", data, off)[0]
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def _bcd2dec(value: int) -> int:
|
|
104
|
-
return ((value >> 4) & 0x0F) * 10 + (value & 0x0F)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def _bcd_bytes(data: bytes, off: int, n: int) -> int:
|
|
108
|
-
result = 0
|
|
109
|
-
for i in range(n):
|
|
110
|
-
result = result * 100 + _bcd2dec(data[off + i])
|
|
111
|
-
return result
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
@dataclass
|
|
115
|
-
class PnfSample:
|
|
116
|
-
t_s: float
|
|
117
|
-
depth_m: float | None = None
|
|
118
|
-
temp_c: float | None = None
|
|
119
|
-
stop_depth_m: float | None = None
|
|
120
|
-
stop_or_ndl_min: float | None = None
|
|
121
|
-
tts_min: float | None = None
|
|
122
|
-
in_deco: int | None = None
|
|
123
|
-
avg_ppo2: float | None = None
|
|
124
|
-
o2_pct: float | None = None
|
|
125
|
-
he_pct: float | None = None
|
|
126
|
-
setpoint: float | None = None
|
|
127
|
-
cns_pct: float | None = None
|
|
128
|
-
gf99: float | None = None
|
|
129
|
-
ceiling_m: float | None = None
|
|
130
|
-
sensor1_raw: int | None = None
|
|
131
|
-
sensor2_raw: int | None = None
|
|
132
|
-
sensor3_raw: int | None = None
|
|
133
|
-
sensor1_ppo2: float | None = None
|
|
134
|
-
sensor2_ppo2: float | None = None
|
|
135
|
-
sensor3_ppo2: float | None = None
|
|
136
|
-
battery_v: float | None = None
|
|
137
|
-
status_flags: int | None = None
|
|
138
|
-
tank0_psi: float | None = None
|
|
139
|
-
tank1_psi: float | None = None
|
|
140
|
-
gas_time_min: float | None = None
|
|
141
|
-
extra: dict = field(default_factory=dict)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
@dataclass
|
|
145
|
-
class PnfGas:
|
|
146
|
-
index: int
|
|
147
|
-
o2_pct: int
|
|
148
|
-
he_pct: int
|
|
149
|
-
diluent: bool
|
|
150
|
-
enabled: bool
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
@dataclass
|
|
154
|
-
class PnfEvent:
|
|
155
|
-
t_s: float
|
|
156
|
-
event: int
|
|
157
|
-
timestamp: int
|
|
158
|
-
w1: int
|
|
159
|
-
w2: int
|
|
160
|
-
raw_hex: str
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
@dataclass
|
|
164
|
-
class PnfDive:
|
|
165
|
-
header: dict
|
|
166
|
-
samples: list[PnfSample]
|
|
167
|
-
gases: list[PnfGas]
|
|
168
|
-
events: list[PnfEvent]
|
|
169
|
-
unknown_records: list[tuple[str, int, bytes]] # (type_key, seq, raw)
|
|
170
|
-
sample_interval_ms: int
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def decompress(blob: bytes) -> bytes:
|
|
174
|
-
"""Strip the 4-byte LE length prefix and gunzip; verify the length."""
|
|
175
|
-
expected = struct.unpack_from("<I", blob, 0)[0]
|
|
176
|
-
data = gzip.decompress(blob[4:])
|
|
177
|
-
if len(data) != expected:
|
|
178
|
-
raise ValueError(f"PNF length mismatch: prefix {expected}, got {len(data)}")
|
|
179
|
-
if len(data) % RECORD_SIZE:
|
|
180
|
-
raise ValueError(f"PNF payload not a multiple of {RECORD_SIZE} bytes")
|
|
181
|
-
return data
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def decode(blob: bytes) -> PnfDive:
|
|
185
|
-
"""Decode a raw sw-pnf blob (as stored in Shearwater Cloud's log_data)."""
|
|
186
|
-
data = decompress(blob)
|
|
187
|
-
|
|
188
|
-
opening: dict[int, int] = {}
|
|
189
|
-
closing: dict[int, int] = {}
|
|
190
|
-
final_off: int | None = None
|
|
191
|
-
raw_records: list[tuple[int, int]] = [] # (type, offset), stream order
|
|
192
|
-
|
|
193
|
-
for off in range(0, len(data), RECORD_SIZE):
|
|
194
|
-
rec = data[off : off + RECORD_SIZE]
|
|
195
|
-
if rec == b"\x00" * RECORD_SIZE:
|
|
196
|
-
continue
|
|
197
|
-
rtype = rec[0]
|
|
198
|
-
raw_records.append((rtype, off))
|
|
199
|
-
if OPENING_0 <= rtype <= OPENING_9:
|
|
200
|
-
opening[rtype - OPENING_0] = off
|
|
201
|
-
elif CLOSING_0 <= rtype <= CLOSING_9:
|
|
202
|
-
closing[rtype - CLOSING_0] = off
|
|
203
|
-
elif rtype == FINAL:
|
|
204
|
-
final_off = off
|
|
205
|
-
|
|
206
|
-
for i in range(5):
|
|
207
|
-
if i not in opening or i not in closing:
|
|
208
|
-
raise ValueError(f"missing opening/closing record {i}")
|
|
209
|
-
|
|
210
|
-
header = _parse_header(data, opening, closing, final_off)
|
|
211
|
-
interval_ms = header["sample_interval_ms"]
|
|
212
|
-
calibrated = header["sensor_calibrated_mask"]
|
|
213
|
-
calibration = header["sensor_calibration"]
|
|
214
|
-
imperial = header["imperial"]
|
|
215
|
-
|
|
216
|
-
gases = [
|
|
217
|
-
PnfGas(
|
|
218
|
-
index=i,
|
|
219
|
-
o2_pct=header["gas_o2"][i],
|
|
220
|
-
he_pct=header["gas_he"][i],
|
|
221
|
-
diluent=i >= 5,
|
|
222
|
-
enabled=bool(header["gas_enabled_mask"] & (1 << i)),
|
|
223
|
-
)
|
|
224
|
-
for i in range(10)
|
|
225
|
-
]
|
|
226
|
-
|
|
227
|
-
samples: list[PnfSample] = []
|
|
228
|
-
events: list[PnfEvent] = []
|
|
229
|
-
unknown: list[tuple[str, int, bytes]] = []
|
|
230
|
-
seq = 0
|
|
231
|
-
t = 0.0
|
|
232
|
-
|
|
233
|
-
for rtype, off in raw_records:
|
|
234
|
-
rec = data[off : off + RECORD_SIZE]
|
|
235
|
-
if rtype in (DIVE_SAMPLE, AVELO_SAMPLE):
|
|
236
|
-
samples.append(
|
|
237
|
-
_parse_sample(rec, t, imperial, calibrated, calibration, rtype)
|
|
238
|
-
)
|
|
239
|
-
t += interval_ms / 1000.0
|
|
240
|
-
elif rtype == FREEDIVE_SAMPLE:
|
|
241
|
-
for i in range(4):
|
|
242
|
-
sub = rec[i * 8 : (i + 1) * 8]
|
|
243
|
-
if sub == b"\x00" * 8:
|
|
244
|
-
break
|
|
245
|
-
pressure_mbar = _u16be(sub, 1)
|
|
246
|
-
depth = (
|
|
247
|
-
(pressure_mbar - header["atmospheric_mbar"])
|
|
248
|
-
* 100.0
|
|
249
|
-
/ (header["water_density"] * 9.80665)
|
|
250
|
-
)
|
|
251
|
-
temp = struct.unpack_from(">h", sub, 3)[0] / 10.0
|
|
252
|
-
samples.append(PnfSample(t_s=t, depth_m=round(depth, 2), temp_c=temp))
|
|
253
|
-
t += interval_ms / 1000.0
|
|
254
|
-
elif rtype == DIVE_SAMPLE_EXT:
|
|
255
|
-
s = samples[-1] if samples else None
|
|
256
|
-
for i, key in enumerate(("tank2_psi", "tank3_psi")):
|
|
257
|
-
v = _u16be(rec, 1 + i * 2)
|
|
258
|
-
if v < 0xFFF0 and (v & 0x0FFF) and s is not None:
|
|
259
|
-
s.extra[key] = (v & 0x0FFF) * 2
|
|
260
|
-
for i, key in enumerate(("tank_dil_psi", "tank_o2_psi")):
|
|
261
|
-
v = _u16be(rec, 1 + 4 + i * 2)
|
|
262
|
-
if v and s is not None:
|
|
263
|
-
s.extra[key] = v * 2
|
|
264
|
-
elif rtype == INFO_EVENT:
|
|
265
|
-
events.append(
|
|
266
|
-
PnfEvent(
|
|
267
|
-
t_s=t,
|
|
268
|
-
event=rec[1],
|
|
269
|
-
timestamp=_u32be(rec, 4),
|
|
270
|
-
w1=_u32be(rec, 8),
|
|
271
|
-
w2=_u32be(rec, 12),
|
|
272
|
-
raw_hex=rec.hex(),
|
|
273
|
-
)
|
|
274
|
-
)
|
|
275
|
-
elif OPENING_0 <= rtype <= OPENING_9 or CLOSING_0 <= rtype <= CLOSING_9 or rtype == FINAL:
|
|
276
|
-
pass # consumed by _parse_header; raw copies kept in header dict
|
|
277
|
-
else:
|
|
278
|
-
unknown.append((f"0x{rtype:02x}", seq, rec))
|
|
279
|
-
seq += 1
|
|
280
|
-
|
|
281
|
-
return PnfDive(
|
|
282
|
-
header=header,
|
|
283
|
-
samples=samples,
|
|
284
|
-
gases=gases,
|
|
285
|
-
events=events,
|
|
286
|
-
unknown_records=unknown,
|
|
287
|
-
sample_interval_ms=interval_ms,
|
|
288
|
-
)
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
def _parse_sample(
|
|
292
|
-
rec: bytes,
|
|
293
|
-
t: float,
|
|
294
|
-
imperial: bool,
|
|
295
|
-
calibrated: int,
|
|
296
|
-
calibration: list[float],
|
|
297
|
-
rtype: int,
|
|
298
|
-
) -> PnfSample:
|
|
299
|
-
ft = 0.3048
|
|
300
|
-
depth = _u16be(rec, 1) / 10.0
|
|
301
|
-
stop_depth = float(_u16be(rec, 3))
|
|
302
|
-
if imperial:
|
|
303
|
-
depth *= ft
|
|
304
|
-
stop_depth *= ft
|
|
305
|
-
|
|
306
|
-
temp = struct.unpack_from("b", rec, 14)[0]
|
|
307
|
-
if temp < 0:
|
|
308
|
-
temp = min(temp + 102, 0)
|
|
309
|
-
if imperial:
|
|
310
|
-
temp = (temp - 32.0) * 5.0 / 9.0
|
|
311
|
-
|
|
312
|
-
status = rec[12] if rtype != AVELO_SAMPLE else 0
|
|
313
|
-
ccr = (status & FLAG_OC) == 0 if rtype != AVELO_SAMPLE else False
|
|
314
|
-
|
|
315
|
-
sample = PnfSample(
|
|
316
|
-
t_s=t,
|
|
317
|
-
depth_m=round(depth, 2),
|
|
318
|
-
temp_c=float(temp),
|
|
319
|
-
stop_depth_m=stop_depth,
|
|
320
|
-
tts_min=float(_u16be(rec, 5)),
|
|
321
|
-
stop_or_ndl_min=float(rec[10]),
|
|
322
|
-
in_deco=1 if _u16be(rec, 3) else 0,
|
|
323
|
-
avg_ppo2=rec[7] / 100.0,
|
|
324
|
-
o2_pct=float(rec[8]),
|
|
325
|
-
he_pct=float(rec[9]),
|
|
326
|
-
status_flags=status,
|
|
327
|
-
sensor1_raw=rec[13],
|
|
328
|
-
sensor2_raw=rec[15],
|
|
329
|
-
sensor3_raw=rec[16],
|
|
330
|
-
cns_pct=float(rec[23]),
|
|
331
|
-
ceiling_m=float(rec[24]) if rec[24] != 0xFF else None,
|
|
332
|
-
gf99=float(rec[25]) if rec[25] != 0xFF else None, # 0xFF = not available
|
|
333
|
-
battery_v=rec[18] / 100.0,
|
|
334
|
-
)
|
|
335
|
-
|
|
336
|
-
if ccr:
|
|
337
|
-
sample.setpoint = rec[19] / 100.0
|
|
338
|
-
if not (status & FLAG_PPO2_EXTERNAL):
|
|
339
|
-
if calibrated & 0x01:
|
|
340
|
-
sample.sensor1_ppo2 = round(rec[13] * calibration[0], 3)
|
|
341
|
-
if calibrated & 0x02:
|
|
342
|
-
sample.sensor2_ppo2 = round(rec[15] * calibration[1], 3)
|
|
343
|
-
if calibrated & 0x04:
|
|
344
|
-
sample.sensor3_ppo2 = round(rec[16] * calibration[2], 3)
|
|
345
|
-
|
|
346
|
-
for idx, key in ((20, "tank1_psi"), (28, "tank0_psi")):
|
|
347
|
-
v = _u16be(rec, idx)
|
|
348
|
-
if v < 0xFFF0:
|
|
349
|
-
pressure = v & 0x0FFF
|
|
350
|
-
battery = (v >> 12) & 0x0F
|
|
351
|
-
if key == "tank1_psi":
|
|
352
|
-
sample.tank1_psi = pressure * 2.0
|
|
353
|
-
else:
|
|
354
|
-
sample.tank0_psi = pressure * 2.0
|
|
355
|
-
if battery:
|
|
356
|
-
sample.extra[key + "_battery"] = battery
|
|
357
|
-
|
|
358
|
-
if rec[22] < 0xF0:
|
|
359
|
-
sample.gas_time_min = float(rec[22])
|
|
360
|
-
|
|
361
|
-
# @+5: TTS if the diver stayed 5 more minutes (minutes, u16be)
|
|
362
|
-
at_plus_five = _u16be(rec, 26)
|
|
363
|
-
if at_plus_five != 0xFFFF:
|
|
364
|
-
sample.extra["at_plus_five_min"] = at_plus_five
|
|
365
|
-
|
|
366
|
-
for b in UNMAPPED_SAMPLE_BYTES:
|
|
367
|
-
if rec[b]:
|
|
368
|
-
sample.extra[f"b{b}"] = rec[b]
|
|
369
|
-
|
|
370
|
-
return sample
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
def _parse_header(
|
|
374
|
-
data: bytes,
|
|
375
|
-
opening: dict[int, int],
|
|
376
|
-
closing: dict[int, int],
|
|
377
|
-
final_off: int | None,
|
|
378
|
-
) -> dict:
|
|
379
|
-
o = opening
|
|
380
|
-
c = closing
|
|
381
|
-
|
|
382
|
-
logversion = data[o[4] + 16]
|
|
383
|
-
imperial = data[o[0] + 8] == 1
|
|
384
|
-
|
|
385
|
-
gas_o2 = [data[o[0] + 20 + i] for i in range(10)]
|
|
386
|
-
gas_he = [data[o[0] + 30 + i] for i in range(2)] + [
|
|
387
|
-
data[o[1] + 1 + i] for i in range(8)
|
|
388
|
-
]
|
|
389
|
-
|
|
390
|
-
calib_base = o[3] + 6
|
|
391
|
-
calibrated_mask = data[calib_base]
|
|
392
|
-
calibration = [
|
|
393
|
-
_u16be(data, calib_base + 1 + i * 2) / 100000.0 for i in range(3)
|
|
394
|
-
]
|
|
395
|
-
|
|
396
|
-
interval_ms = 10000
|
|
397
|
-
if logversion >= 9 and 5 in o:
|
|
398
|
-
interval_ms = _u16be(data, o[5] + 23) or 10000
|
|
399
|
-
|
|
400
|
-
max_depth = _u16be(data, c[0] + 4) / 10.0
|
|
401
|
-
if imperial:
|
|
402
|
-
max_depth *= 0.3048
|
|
403
|
-
|
|
404
|
-
header = {
|
|
405
|
-
"logversion": logversion,
|
|
406
|
-
"imperial": imperial,
|
|
407
|
-
"divemode": DIVE_MODES.get(data[o[4] + 1], str(data[o[4] + 1])),
|
|
408
|
-
"gf_low": data[o[0] + 4],
|
|
409
|
-
"gf_high": data[o[0] + 5],
|
|
410
|
-
"deco_model": DECO_MODELS.get(data[o[2] + 18], str(data[o[2] + 18])),
|
|
411
|
-
"vpmb_conservatism": data[o[2] + 19],
|
|
412
|
-
"atmospheric_mbar": _u16be(data, o[1] + 16),
|
|
413
|
-
"water_density": _u16be(data, o[3] + 3),
|
|
414
|
-
"sensor_calibrated_mask": calibrated_mask,
|
|
415
|
-
"sensor_calibration": calibration,
|
|
416
|
-
"gas_o2": gas_o2,
|
|
417
|
-
"gas_he": gas_he,
|
|
418
|
-
"gas_enabled_mask": _u16be(data, o[4] + 17),
|
|
419
|
-
"ai_mode": data[o[4] + 28],
|
|
420
|
-
"sample_interval_ms": interval_ms,
|
|
421
|
-
"max_depth_m": round(max_depth, 2),
|
|
422
|
-
"divetime_s": _u24be(data, c[0] + 6),
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
if final_off is not None:
|
|
426
|
-
header["computer_serial"] = _u32be(data, final_off + 2)
|
|
427
|
-
header["computer_firmware"] = _bcd2dec(data[final_off + 10])
|
|
428
|
-
header["computer_model"] = data[final_off + 13]
|
|
429
|
-
|
|
430
|
-
if logversion >= 17:
|
|
431
|
-
for name, recs in (("gnss_start", o), ("gnss_end", c)):
|
|
432
|
-
if 9 in recs:
|
|
433
|
-
off = recs[9]
|
|
434
|
-
fix = data[off + 16]
|
|
435
|
-
if fix in (2, 3): # 2D / 3D fix
|
|
436
|
-
lat = struct.unpack_from(">i", data, off + 21)[0] / 100000.0
|
|
437
|
-
lon = struct.unpack_from(">i", data, off + 25)[0] / 100000.0
|
|
438
|
-
header[name] = {"fix": fix, "lat": lat, "lon": lon}
|
|
439
|
-
|
|
440
|
-
# Verbatim copies of every config record, keyed by record type.
|
|
441
|
-
header["raw_opening"] = {
|
|
442
|
-
f"0x{OPENING_0 + i:02x}": data[off : off + RECORD_SIZE].hex()
|
|
443
|
-
for i, off in sorted(o.items())
|
|
444
|
-
}
|
|
445
|
-
header["raw_closing"] = {
|
|
446
|
-
f"0x{CLOSING_0 + i:02x}": data[off : off + RECORD_SIZE].hex()
|
|
447
|
-
for i, off in sorted(c.items())
|
|
448
|
-
}
|
|
449
|
-
if final_off is not None:
|
|
450
|
-
header["raw_final"] = data[final_off : final_off + RECORD_SIZE].hex()
|
|
451
|
-
|
|
452
|
-
return header
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
"""PNF decoder tests against a synthetic blob built record by record."""
|
|
2
|
-
|
|
3
|
-
import gzip
|
|
4
|
-
import struct
|
|
5
|
-
|
|
6
|
-
import pytest
|
|
7
|
-
|
|
8
|
-
from bottomtime.decode import pnf
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def _record(rtype: int, payload: dict[int, bytes] | None = None) -> bytearray:
|
|
12
|
-
rec = bytearray(32)
|
|
13
|
-
rec[0] = rtype
|
|
14
|
-
for offset, data in (payload or {}).items():
|
|
15
|
-
rec[offset : offset + len(data)] = data
|
|
16
|
-
return rec
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def _sample(depth_dm: int, **kw) -> bytearray:
|
|
20
|
-
rec = _record(pnf.DIVE_SAMPLE)
|
|
21
|
-
struct.pack_into(">H", rec, 1, depth_dm)
|
|
22
|
-
struct.pack_into(">H", rec, 3, kw.get("stop_m", 0))
|
|
23
|
-
struct.pack_into(">H", rec, 5, kw.get("tts_min", 0))
|
|
24
|
-
rec[7] = kw.get("ppo2_raw", 21)
|
|
25
|
-
rec[8] = kw.get("o2", 21)
|
|
26
|
-
rec[9] = kw.get("he", 0)
|
|
27
|
-
rec[10] = kw.get("ndl_min", 99)
|
|
28
|
-
rec[12] = kw.get("flags", pnf.FLAG_OC)
|
|
29
|
-
rec[14] = kw.get("temp", 27) & 0xFF
|
|
30
|
-
rec[18] = kw.get("battery_raw", 152)
|
|
31
|
-
struct.pack_into(">H", rec, 20, 0xFFFF) # AI off
|
|
32
|
-
rec[22] = 0xFF # gas time not paired
|
|
33
|
-
rec[23] = kw.get("cns", 0)
|
|
34
|
-
rec[24] = kw.get("ceiling", 0xFF)
|
|
35
|
-
rec[25] = kw.get("gf99", 0xFF)
|
|
36
|
-
struct.pack_into(">H", rec, 26, kw.get("at_plus_five", 0))
|
|
37
|
-
struct.pack_into(">H", rec, 28, 0xFFFF) # AI off
|
|
38
|
-
return rec
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def build_blob(samples=None, interval_ms=10000, extra_records=None) -> bytes:
|
|
42
|
-
records = []
|
|
43
|
-
|
|
44
|
-
opening0 = _record(0x10)
|
|
45
|
-
opening0[4], opening0[5] = 40, 80 # GF low/high
|
|
46
|
-
opening0[8] = 0 # metric
|
|
47
|
-
opening0[20] = 21 # gas 0 O2
|
|
48
|
-
opening0[21] = 50 # gas 1 O2
|
|
49
|
-
records.append(opening0)
|
|
50
|
-
|
|
51
|
-
opening1 = _record(0x11)
|
|
52
|
-
struct.pack_into(">H", opening1, 16, 1013) # atmospheric mbar
|
|
53
|
-
records.append(opening1)
|
|
54
|
-
|
|
55
|
-
opening2 = _record(0x12)
|
|
56
|
-
opening2[18] = 0 # GF deco model
|
|
57
|
-
records.append(opening2)
|
|
58
|
-
|
|
59
|
-
opening3 = _record(0x13)
|
|
60
|
-
struct.pack_into(">H", opening3, 3, 1020) # density
|
|
61
|
-
records.append(opening3)
|
|
62
|
-
|
|
63
|
-
opening4 = _record(0x14)
|
|
64
|
-
opening4[1] = 1 # OC tec
|
|
65
|
-
opening4[16] = 13 # log version
|
|
66
|
-
struct.pack_into(">H", opening4, 17, 0b11) # gases 0,1 enabled
|
|
67
|
-
records.append(opening4)
|
|
68
|
-
|
|
69
|
-
opening5 = _record(0x15)
|
|
70
|
-
struct.pack_into(">H", opening5, 23, interval_ms)
|
|
71
|
-
records.append(opening5)
|
|
72
|
-
|
|
73
|
-
records.extend(samples or [])
|
|
74
|
-
records.extend(extra_records or [])
|
|
75
|
-
|
|
76
|
-
for i in range(5):
|
|
77
|
-
rec = _record(0x20 + i)
|
|
78
|
-
if i == 0:
|
|
79
|
-
struct.pack_into(">H", rec, 4, 528) # max depth dm
|
|
80
|
-
rec[6:9] = (3374).to_bytes(3, "big")
|
|
81
|
-
records.append(rec)
|
|
82
|
-
|
|
83
|
-
final = _record(0xFF)
|
|
84
|
-
struct.pack_into(">I", final, 2, 0xA823C228)
|
|
85
|
-
final[13] = 11
|
|
86
|
-
records.append(final)
|
|
87
|
-
|
|
88
|
-
raw = b"".join(bytes(r) for r in records)
|
|
89
|
-
return struct.pack("<I", len(raw)) + gzip.compress(raw)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def test_header():
|
|
93
|
-
dive = pnf.decode(build_blob(samples=[_sample(24)]))
|
|
94
|
-
h = dive.header
|
|
95
|
-
assert (h["gf_low"], h["gf_high"]) == (40, 80)
|
|
96
|
-
assert h["deco_model"] == "gf"
|
|
97
|
-
assert h["atmospheric_mbar"] == 1013
|
|
98
|
-
assert h["water_density"] == 1020
|
|
99
|
-
assert h["divemode"] == "oc_tec"
|
|
100
|
-
assert h["max_depth_m"] == 52.8
|
|
101
|
-
assert h["divetime_s"] == 3374
|
|
102
|
-
assert h["computer_serial"] == 0xA823C228
|
|
103
|
-
assert h["sample_interval_ms"] == 10000
|
|
104
|
-
assert [g for g in dive.gases if g.enabled and g.o2_pct] == dive.gases[:2]
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def test_samples():
|
|
108
|
-
dive = pnf.decode(
|
|
109
|
-
build_blob(
|
|
110
|
-
samples=[
|
|
111
|
-
_sample(24, ndl_min=99),
|
|
112
|
-
_sample(528, stop_m=9, tts_min=14, cns=4, ceiling=9, gf99=30, o2=21),
|
|
113
|
-
]
|
|
114
|
-
)
|
|
115
|
-
)
|
|
116
|
-
assert len(dive.samples) == 2
|
|
117
|
-
s0, s1 = dive.samples
|
|
118
|
-
assert (s0.t_s, s1.t_s) == (0.0, 10.0)
|
|
119
|
-
assert s0.depth_m == 2.4
|
|
120
|
-
assert s0.gf99 is None and s0.ceiling_m is None # 0xFF sentinel
|
|
121
|
-
assert s0.battery_v == 1.52
|
|
122
|
-
assert s0.tank0_psi is None and s0.gas_time_min is None
|
|
123
|
-
assert s1.depth_m == 52.8
|
|
124
|
-
assert (s1.stop_depth_m, s1.tts_min, s1.in_deco) == (9.0, 14.0, 1)
|
|
125
|
-
assert (s1.ceiling_m, s1.gf99, s1.cns_pct) == (9.0, 30.0, 4.0)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def test_unknown_records_preserved():
|
|
129
|
-
mystery = _record(0x51, {1: b"\xde\xad\xbe\xef"})
|
|
130
|
-
dive = pnf.decode(build_blob(samples=[_sample(24)], extra_records=[mystery]))
|
|
131
|
-
assert len(dive.unknown_records) == 1
|
|
132
|
-
type_key, _seq, raw = dive.unknown_records[0]
|
|
133
|
-
assert type_key == "0x51"
|
|
134
|
-
assert raw == bytes(mystery)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
def test_length_prefix_validated():
|
|
138
|
-
blob = build_blob(samples=[_sample(24)])
|
|
139
|
-
corrupted = struct.pack("<I", 999999) + blob[4:]
|
|
140
|
-
with pytest.raises(ValueError, match="length mismatch"):
|
|
141
|
-
pnf.decode(corrupted)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def test_imperial_units():
|
|
145
|
-
samples = [_sample(100)] # 10.0 units
|
|
146
|
-
blob = build_blob(samples=samples)
|
|
147
|
-
dive = pnf.decode(blob)
|
|
148
|
-
assert dive.samples[0].depth_m == 10.0 # metric build
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{bottomtime-0.2.0/src/bottomtime/decode → bottomtime-0.4.0/src/bottomtime/ingest}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{bottomtime-0.2.0/src/bottomtime/ingest → bottomtime-0.4.0/src/bottomtime/match}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{bottomtime-0.2.0/src/bottomtime/store → bottomtime-0.4.0/src/bottomtime/verify}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|