bottomtime 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """bottomtime: lossless dive-log store for multi-computer divers."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .api import list_dives, load_dive
6
+
7
+ __all__ = ["load_dive", "list_dives", "__version__"]
bottomtime/api.py ADDED
@@ -0,0 +1,88 @@
1
+ """Small read API for analysis code.
2
+
3
+ import bottomtime
4
+ dive = bottomtime.load_dive("data/dives.db", 291)
5
+ dive["sources"]["garmin"]["samples"]["depth_m"]
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ from pathlib import Path
13
+
14
+ _SAMPLE_TABLES = {"garmin": "garmin_samples", "shearwater": "shearwater_samples"}
15
+
16
+
17
+ def _columns_of(con: sqlite3.Connection, table: str) -> list[str]:
18
+ return [r[1] for r in con.execute(f"PRAGMA table_info({table})")]
19
+
20
+
21
+ def load_dive(db_path: str | Path, dive_number: int) -> dict:
22
+ """Load one canonical dive with every member source log's samples as
23
+ column-oriented lists (ready for plotting / DataFrame construction)."""
24
+ con = sqlite3.connect(db_path)
25
+ con.row_factory = sqlite3.Row
26
+ try:
27
+ dive = con.execute(
28
+ "SELECT * FROM dives WHERE dive_number=?", (dive_number,)
29
+ ).fetchone()
30
+ if dive is None:
31
+ raise KeyError(f"no dive with number {dive_number}")
32
+ out = {k: dive[k] for k in dive.keys()}
33
+ out["sources"] = {}
34
+ members = con.execute(
35
+ "SELECT sd.* FROM source_dives sd JOIN dive_members dm"
36
+ " ON dm.source_dive_id = sd.id WHERE dm.dive_id=?",
37
+ (dive["id"],),
38
+ ).fetchall()
39
+ for sd in members:
40
+ table = _SAMPLE_TABLES[sd["source"]]
41
+ cols = [c for c in _columns_of(con, table) if c != "source_dive_id"]
42
+ rows = con.execute(
43
+ f"SELECT {', '.join(cols)} FROM {table}"
44
+ " WHERE source_dive_id=? ORDER BY t_s",
45
+ (sd["id"],),
46
+ ).fetchall()
47
+ samples = {c: [r[i] for r in rows] for i, c in enumerate(cols)}
48
+ entry = {
49
+ "source_dive_id": sd["id"],
50
+ "source_key": sd["source_key"],
51
+ "start_time_utc": sd["start_time_utc"],
52
+ "start_time_local": sd["start_time_local"],
53
+ "duration_s": sd["duration_s"],
54
+ "max_depth_m": sd["max_depth_m"],
55
+ "mode": sd["mode"],
56
+ "header": json.loads(sd["header_json"]),
57
+ "samples": samples,
58
+ }
59
+ key = sd["source"]
60
+ if key in out["sources"]: # several logs from one source (splits)
61
+ existing = out["sources"][key]
62
+ if isinstance(existing, list):
63
+ existing.append(entry)
64
+ else:
65
+ out["sources"][key] = [existing, entry]
66
+ else:
67
+ out["sources"][key] = entry
68
+ return out
69
+ finally:
70
+ con.close()
71
+
72
+
73
+ def list_dives(db_path: str | Path, include_tests: bool = False) -> list[dict]:
74
+ con = sqlite3.connect(db_path)
75
+ con.row_factory = sqlite3.Row
76
+ try:
77
+ where = "" if include_tests else "WHERE d.is_test = 0"
78
+ return [
79
+ {k: r[k] for k in r.keys()}
80
+ for r in con.execute(
81
+ "SELECT d.*, group_concat(sd.source) AS sources FROM dives d"
82
+ " JOIN dive_members dm ON dm.dive_id = d.id"
83
+ " JOIN source_dives sd ON sd.id = dm.source_dive_id"
84
+ f" {where} GROUP BY d.id ORDER BY d.start_time_utc"
85
+ )
86
+ ]
87
+ finally:
88
+ con.close()
bottomtime/cli.py ADDED
@@ -0,0 +1,107 @@
1
+ """bottomtime command line interface.
2
+
3
+ bottomtime init
4
+ bottomtime ingest garmin <fit-dir>
5
+ bottomtime ingest shearwater <dive_data.db>
6
+ bottomtime match
7
+ bottomtime verify [--xml-dir DIR]
8
+ bottomtime status
9
+
10
+ All commands are idempotent: re-running an ingest skips anything already
11
+ stored (content-hash keyed), match recomputes only 'auto' links.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from .store import db as store_db
21
+
22
+
23
+ def main(argv: list[str] | None = None) -> int:
24
+ parser = argparse.ArgumentParser(prog="bottomtime")
25
+ parser.add_argument("--db", default="data/dives.db", help="path to the dive store")
26
+ parser.add_argument(
27
+ "--archive", default="archive", help="directory for verbatim raw source copies"
28
+ )
29
+ sub = parser.add_subparsers(dest="command", required=True)
30
+
31
+ sub.add_parser("init", help="create an empty dive store")
32
+
33
+ p_ingest = sub.add_parser("ingest", help="ingest a data source")
34
+ ingest_sub = p_ingest.add_subparsers(dest="source", required=True)
35
+ p_garmin = ingest_sub.add_parser("garmin", help="a directory of FIT files")
36
+ p_garmin.add_argument("fit_dir", type=Path)
37
+ p_garmin.add_argument(
38
+ "--index",
39
+ default="dives_index.json",
40
+ help="Garmin Connect index JSON filename inside fit-dir",
41
+ )
42
+ p_sw = ingest_sub.add_parser("shearwater", help="a Shearwater Cloud dive_data.db")
43
+ p_sw.add_argument("db_path", type=Path)
44
+
45
+ sub.add_parser("match", help="match dual-computer dives, build canonical dives")
46
+
47
+ p_verify = sub.add_parser("verify", help="run the verification suite")
48
+ p_verify.add_argument("--xml-dir", type=Path, default=None,
49
+ help="directory of Shearwater XML exports to cross-check")
50
+
51
+ sub.add_parser("status", help="summarize the store")
52
+
53
+ args = parser.parse_args(argv)
54
+ con = store_db.connect(args.db)
55
+ archive_dir = Path(args.archive)
56
+
57
+ if args.command == "init":
58
+ print(f"initialized {args.db}")
59
+ return 0
60
+
61
+ if args.command == "ingest" and args.source == "garmin":
62
+ from .ingest.garmin_fit import ingest_garmin_dir
63
+
64
+ stats = ingest_garmin_dir(con, archive_dir, args.fit_dir, args.index)
65
+ print(f"garmin: {stats}")
66
+ return 1 if stats["failed"] else 0
67
+
68
+ if args.command == "ingest" and args.source == "shearwater":
69
+ from .ingest.shearwater_db import ingest_shearwater_db
70
+
71
+ stats = ingest_shearwater_db(con, archive_dir, args.db_path)
72
+ print(f"shearwater: {stats}")
73
+ return 1 if stats["failed"] else 0
74
+
75
+ if args.command == "match":
76
+ from .match.matcher import run_match
77
+
78
+ stats = run_match(con)
79
+ print(f"match: {stats}")
80
+ return 0
81
+
82
+ if args.command == "verify":
83
+ from .verify.crosscheck import run_verify
84
+
85
+ return run_verify(con, args.xml_dir)
86
+
87
+ if args.command == "status":
88
+ for line in con.execute(
89
+ "SELECT source || ' source dives: ' || count(*) FROM source_dives GROUP BY source"
90
+ ):
91
+ print(line[0])
92
+ for line in con.execute(
93
+ "SELECT 'canonical dives: ' || count(*) || ' (' ||"
94
+ " sum(CASE WHEN is_test THEN 1 ELSE 0 END) || ' test)' FROM dives"
95
+ ):
96
+ print(line[0])
97
+ for line in con.execute(
98
+ "SELECT 'matched pairs: ' || count(*) FROM matches WHERE status IN ('auto','confirmed')"
99
+ ):
100
+ print(line[0])
101
+ return 0
102
+
103
+ return 2
104
+
105
+
106
+ if __name__ == "__main__":
107
+ sys.exit(main())
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,435 @@
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
+ # Sample bytes not (yet) assigned a meaning; kept per sample as b<offset>.
69
+ # 18 (battery), 24 (ceiling), 25 (gf99), 26-27 (@+5) were identified
70
+ # empirically and promoted in v2. 30-31 read 0xFFFF (sentinel) so far.
71
+ UNMAPPED_SAMPLE_BYTES = (11, 17, 30, 31)
72
+
73
+
74
+ def _u16be(data: bytes, off: int) -> int:
75
+ return struct.unpack_from(">H", data, off)[0]
76
+
77
+
78
+ def _u24be(data: bytes, off: int) -> int:
79
+ return (data[off] << 16) | (data[off + 1] << 8) | data[off + 2]
80
+
81
+
82
+ def _u32be(data: bytes, off: int) -> int:
83
+ return struct.unpack_from(">I", data, off)[0]
84
+
85
+
86
+ def _bcd2dec(value: int) -> int:
87
+ return ((value >> 4) & 0x0F) * 10 + (value & 0x0F)
88
+
89
+
90
+ def _bcd_bytes(data: bytes, off: int, n: int) -> int:
91
+ result = 0
92
+ for i in range(n):
93
+ result = result * 100 + _bcd2dec(data[off + i])
94
+ return result
95
+
96
+
97
+ @dataclass
98
+ class PnfSample:
99
+ t_s: float
100
+ depth_m: float | None = None
101
+ temp_c: float | None = None
102
+ stop_depth_m: float | None = None
103
+ stop_or_ndl_min: float | None = None
104
+ tts_min: float | None = None
105
+ in_deco: int | None = None
106
+ avg_ppo2: float | None = None
107
+ o2_pct: float | None = None
108
+ he_pct: float | None = None
109
+ setpoint: float | None = None
110
+ cns_pct: float | None = None
111
+ gf99: float | None = None
112
+ ceiling_m: float | None = None
113
+ sensor1_raw: int | None = None
114
+ sensor2_raw: int | None = None
115
+ sensor3_raw: int | None = None
116
+ sensor1_ppo2: float | None = None
117
+ sensor2_ppo2: float | None = None
118
+ sensor3_ppo2: float | None = None
119
+ battery_v: float | None = None
120
+ status_flags: int | None = None
121
+ tank0_psi: float | None = None
122
+ tank1_psi: float | None = None
123
+ gas_time_min: float | None = None
124
+ extra: dict = field(default_factory=dict)
125
+
126
+
127
+ @dataclass
128
+ class PnfGas:
129
+ index: int
130
+ o2_pct: int
131
+ he_pct: int
132
+ diluent: bool
133
+ enabled: bool
134
+
135
+
136
+ @dataclass
137
+ class PnfEvent:
138
+ t_s: float
139
+ event: int
140
+ timestamp: int
141
+ w1: int
142
+ w2: int
143
+ raw_hex: str
144
+
145
+
146
+ @dataclass
147
+ class PnfDive:
148
+ header: dict
149
+ samples: list[PnfSample]
150
+ gases: list[PnfGas]
151
+ events: list[PnfEvent]
152
+ unknown_records: list[tuple[str, int, bytes]] # (type_key, seq, raw)
153
+ sample_interval_ms: int
154
+
155
+
156
+ def decompress(blob: bytes) -> bytes:
157
+ """Strip the 4-byte LE length prefix and gunzip; verify the length."""
158
+ expected = struct.unpack_from("<I", blob, 0)[0]
159
+ data = gzip.decompress(blob[4:])
160
+ if len(data) != expected:
161
+ raise ValueError(f"PNF length mismatch: prefix {expected}, got {len(data)}")
162
+ if len(data) % RECORD_SIZE:
163
+ raise ValueError(f"PNF payload not a multiple of {RECORD_SIZE} bytes")
164
+ return data
165
+
166
+
167
+ def decode(blob: bytes) -> PnfDive:
168
+ """Decode a raw sw-pnf blob (as stored in Shearwater Cloud's log_data)."""
169
+ data = decompress(blob)
170
+
171
+ opening: dict[int, int] = {}
172
+ closing: dict[int, int] = {}
173
+ final_off: int | None = None
174
+ raw_records: list[tuple[int, int]] = [] # (type, offset), stream order
175
+
176
+ for off in range(0, len(data), RECORD_SIZE):
177
+ rec = data[off : off + RECORD_SIZE]
178
+ if rec == b"\x00" * RECORD_SIZE:
179
+ continue
180
+ rtype = rec[0]
181
+ raw_records.append((rtype, off))
182
+ if OPENING_0 <= rtype <= OPENING_9:
183
+ opening[rtype - OPENING_0] = off
184
+ elif CLOSING_0 <= rtype <= CLOSING_9:
185
+ closing[rtype - CLOSING_0] = off
186
+ elif rtype == FINAL:
187
+ final_off = off
188
+
189
+ for i in range(5):
190
+ if i not in opening or i not in closing:
191
+ raise ValueError(f"missing opening/closing record {i}")
192
+
193
+ header = _parse_header(data, opening, closing, final_off)
194
+ interval_ms = header["sample_interval_ms"]
195
+ calibrated = header["sensor_calibrated_mask"]
196
+ calibration = header["sensor_calibration"]
197
+ imperial = header["imperial"]
198
+
199
+ gases = [
200
+ PnfGas(
201
+ index=i,
202
+ o2_pct=header["gas_o2"][i],
203
+ he_pct=header["gas_he"][i],
204
+ diluent=i >= 5,
205
+ enabled=bool(header["gas_enabled_mask"] & (1 << i)),
206
+ )
207
+ for i in range(10)
208
+ ]
209
+
210
+ samples: list[PnfSample] = []
211
+ events: list[PnfEvent] = []
212
+ unknown: list[tuple[str, int, bytes]] = []
213
+ seq = 0
214
+ t = 0.0
215
+
216
+ for rtype, off in raw_records:
217
+ rec = data[off : off + RECORD_SIZE]
218
+ if rtype in (DIVE_SAMPLE, AVELO_SAMPLE):
219
+ samples.append(
220
+ _parse_sample(rec, t, imperial, calibrated, calibration, rtype)
221
+ )
222
+ t += interval_ms / 1000.0
223
+ elif rtype == FREEDIVE_SAMPLE:
224
+ for i in range(4):
225
+ sub = rec[i * 8 : (i + 1) * 8]
226
+ if sub == b"\x00" * 8:
227
+ break
228
+ pressure_mbar = _u16be(sub, 1)
229
+ depth = (
230
+ (pressure_mbar - header["atmospheric_mbar"])
231
+ * 100.0
232
+ / (header["water_density"] * 9.80665)
233
+ )
234
+ temp = struct.unpack_from(">h", sub, 3)[0] / 10.0
235
+ samples.append(PnfSample(t_s=t, depth_m=round(depth, 2), temp_c=temp))
236
+ t += interval_ms / 1000.0
237
+ elif rtype == DIVE_SAMPLE_EXT:
238
+ s = samples[-1] if samples else None
239
+ for i, key in enumerate(("tank2_psi", "tank3_psi")):
240
+ v = _u16be(rec, 1 + i * 2)
241
+ if v < 0xFFF0 and (v & 0x0FFF) and s is not None:
242
+ s.extra[key] = (v & 0x0FFF) * 2
243
+ for i, key in enumerate(("tank_dil_psi", "tank_o2_psi")):
244
+ v = _u16be(rec, 1 + 4 + i * 2)
245
+ if v and s is not None:
246
+ s.extra[key] = v * 2
247
+ elif rtype == INFO_EVENT:
248
+ events.append(
249
+ PnfEvent(
250
+ t_s=t,
251
+ event=rec[1],
252
+ timestamp=_u32be(rec, 4),
253
+ w1=_u32be(rec, 8),
254
+ w2=_u32be(rec, 12),
255
+ raw_hex=rec.hex(),
256
+ )
257
+ )
258
+ elif OPENING_0 <= rtype <= OPENING_9 or CLOSING_0 <= rtype <= CLOSING_9 or rtype == FINAL:
259
+ pass # consumed by _parse_header; raw copies kept in header dict
260
+ else:
261
+ unknown.append((f"0x{rtype:02x}", seq, rec))
262
+ seq += 1
263
+
264
+ return PnfDive(
265
+ header=header,
266
+ samples=samples,
267
+ gases=gases,
268
+ events=events,
269
+ unknown_records=unknown,
270
+ sample_interval_ms=interval_ms,
271
+ )
272
+
273
+
274
+ def _parse_sample(
275
+ rec: bytes,
276
+ t: float,
277
+ imperial: bool,
278
+ calibrated: int,
279
+ calibration: list[float],
280
+ rtype: int,
281
+ ) -> PnfSample:
282
+ ft = 0.3048
283
+ depth = _u16be(rec, 1) / 10.0
284
+ stop_depth = float(_u16be(rec, 3))
285
+ if imperial:
286
+ depth *= ft
287
+ stop_depth *= ft
288
+
289
+ temp = struct.unpack_from("b", rec, 14)[0]
290
+ if temp < 0:
291
+ temp = min(temp + 102, 0)
292
+ if imperial:
293
+ temp = (temp - 32.0) * 5.0 / 9.0
294
+
295
+ status = rec[12] if rtype != AVELO_SAMPLE else 0
296
+ ccr = (status & FLAG_OC) == 0 if rtype != AVELO_SAMPLE else False
297
+
298
+ sample = PnfSample(
299
+ t_s=t,
300
+ depth_m=round(depth, 2),
301
+ temp_c=float(temp),
302
+ stop_depth_m=stop_depth,
303
+ tts_min=float(_u16be(rec, 5)),
304
+ stop_or_ndl_min=float(rec[10]),
305
+ in_deco=1 if _u16be(rec, 3) else 0,
306
+ avg_ppo2=rec[7] / 100.0,
307
+ o2_pct=float(rec[8]),
308
+ he_pct=float(rec[9]),
309
+ status_flags=status,
310
+ sensor1_raw=rec[13],
311
+ sensor2_raw=rec[15],
312
+ sensor3_raw=rec[16],
313
+ cns_pct=float(rec[23]),
314
+ ceiling_m=float(rec[24]) if rec[24] != 0xFF else None,
315
+ gf99=float(rec[25]) if rec[25] != 0xFF else None, # 0xFF = not available
316
+ battery_v=rec[18] / 100.0,
317
+ )
318
+
319
+ if ccr:
320
+ sample.setpoint = rec[19] / 100.0
321
+ if not (status & FLAG_PPO2_EXTERNAL):
322
+ if calibrated & 0x01:
323
+ sample.sensor1_ppo2 = round(rec[13] * calibration[0], 3)
324
+ if calibrated & 0x02:
325
+ sample.sensor2_ppo2 = round(rec[15] * calibration[1], 3)
326
+ if calibrated & 0x04:
327
+ sample.sensor3_ppo2 = round(rec[16] * calibration[2], 3)
328
+
329
+ for idx, key in ((20, "tank1_psi"), (28, "tank0_psi")):
330
+ v = _u16be(rec, idx)
331
+ if v < 0xFFF0:
332
+ pressure = v & 0x0FFF
333
+ battery = (v >> 12) & 0x0F
334
+ if key == "tank1_psi":
335
+ sample.tank1_psi = pressure * 2.0
336
+ else:
337
+ sample.tank0_psi = pressure * 2.0
338
+ if battery:
339
+ sample.extra[key + "_battery"] = battery
340
+
341
+ if rec[22] < 0xF0:
342
+ sample.gas_time_min = float(rec[22])
343
+
344
+ # @+5: TTS if the diver stayed 5 more minutes (minutes, u16be)
345
+ at_plus_five = _u16be(rec, 26)
346
+ if at_plus_five != 0xFFFF:
347
+ sample.extra["at_plus_five_min"] = at_plus_five
348
+
349
+ for b in UNMAPPED_SAMPLE_BYTES:
350
+ if rec[b]:
351
+ sample.extra[f"b{b}"] = rec[b]
352
+
353
+ return sample
354
+
355
+
356
+ def _parse_header(
357
+ data: bytes,
358
+ opening: dict[int, int],
359
+ closing: dict[int, int],
360
+ final_off: int | None,
361
+ ) -> dict:
362
+ o = opening
363
+ c = closing
364
+
365
+ logversion = data[o[4] + 16]
366
+ imperial = data[o[0] + 8] == 1
367
+
368
+ gas_o2 = [data[o[0] + 20 + i] for i in range(10)]
369
+ gas_he = [data[o[0] + 30 + i] for i in range(2)] + [
370
+ data[o[1] + 1 + i] for i in range(8)
371
+ ]
372
+
373
+ calib_base = o[3] + 6
374
+ calibrated_mask = data[calib_base]
375
+ calibration = [
376
+ _u16be(data, calib_base + 1 + i * 2) / 100000.0 for i in range(3)
377
+ ]
378
+
379
+ interval_ms = 10000
380
+ if logversion >= 9 and 5 in o:
381
+ interval_ms = _u16be(data, o[5] + 23) or 10000
382
+
383
+ max_depth = _u16be(data, c[0] + 4) / 10.0
384
+ if imperial:
385
+ max_depth *= 0.3048
386
+
387
+ header = {
388
+ "logversion": logversion,
389
+ "imperial": imperial,
390
+ "divemode": DIVE_MODES.get(data[o[4] + 1], str(data[o[4] + 1])),
391
+ "gf_low": data[o[0] + 4],
392
+ "gf_high": data[o[0] + 5],
393
+ "deco_model": DECO_MODELS.get(data[o[2] + 18], str(data[o[2] + 18])),
394
+ "vpmb_conservatism": data[o[2] + 19],
395
+ "atmospheric_mbar": _u16be(data, o[1] + 16),
396
+ "water_density": _u16be(data, o[3] + 3),
397
+ "sensor_calibrated_mask": calibrated_mask,
398
+ "sensor_calibration": calibration,
399
+ "gas_o2": gas_o2,
400
+ "gas_he": gas_he,
401
+ "gas_enabled_mask": _u16be(data, o[4] + 17),
402
+ "ai_mode": data[o[4] + 28],
403
+ "sample_interval_ms": interval_ms,
404
+ "max_depth_m": round(max_depth, 2),
405
+ "divetime_s": _u24be(data, c[0] + 6),
406
+ }
407
+
408
+ if final_off is not None:
409
+ header["computer_serial"] = _u32be(data, final_off + 2)
410
+ header["computer_firmware"] = _bcd2dec(data[final_off + 10])
411
+ header["computer_model"] = data[final_off + 13]
412
+
413
+ if logversion >= 17:
414
+ for name, recs in (("gnss_start", o), ("gnss_end", c)):
415
+ if 9 in recs:
416
+ off = recs[9]
417
+ fix = data[off + 16]
418
+ if fix in (2, 3): # 2D / 3D fix
419
+ lat = struct.unpack_from(">i", data, off + 21)[0] / 100000.0
420
+ lon = struct.unpack_from(">i", data, off + 25)[0] / 100000.0
421
+ header[name] = {"fix": fix, "lat": lat, "lon": lon}
422
+
423
+ # Verbatim copies of every config record, keyed by record type.
424
+ header["raw_opening"] = {
425
+ f"0x{OPENING_0 + i:02x}": data[off : off + RECORD_SIZE].hex()
426
+ for i, off in sorted(o.items())
427
+ }
428
+ header["raw_closing"] = {
429
+ f"0x{CLOSING_0 + i:02x}": data[off : off + RECORD_SIZE].hex()
430
+ for i, off in sorted(c.items())
431
+ }
432
+ if final_off is not None:
433
+ header["raw_final"] = data[final_off : final_off + RECORD_SIZE].hex()
434
+
435
+ return header
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,48 @@
1
+ """Content-addressed archive of verbatim raw source files.
2
+
3
+ Every ingested file is copied (if absent) to <archive_dir>/<kind>/<sha256><ext>
4
+ and registered in raw_artifacts. The archive, not the database, is the
5
+ byte-level source of truth; re-running is a no-op.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import shutil
12
+ import sqlite3
13
+ from pathlib import Path
14
+
15
+ from ..store.db import utcnow
16
+
17
+
18
+ def sha256_file(path: Path) -> str:
19
+ h = hashlib.sha256()
20
+ with open(path, "rb") as f:
21
+ for chunk in iter(lambda: f.read(1 << 20), b""):
22
+ h.update(chunk)
23
+ return h.hexdigest()
24
+
25
+
26
+ def archive_file(
27
+ con: sqlite3.Connection, archive_dir: Path, kind: str, path: Path
28
+ ) -> tuple[int, str]:
29
+ """Archive path under kind; return (artifact_id, sha256)."""
30
+ digest = sha256_file(path)
31
+ row = con.execute(
32
+ "SELECT id FROM raw_artifacts WHERE sha256=?", (digest,)
33
+ ).fetchone()
34
+ if row:
35
+ return row[0], digest
36
+
37
+ dest_dir = archive_dir / kind
38
+ dest_dir.mkdir(parents=True, exist_ok=True)
39
+ dest = dest_dir / (digest + path.suffix.lower())
40
+ if not dest.exists():
41
+ shutil.copy2(path, dest)
42
+
43
+ cur = con.execute(
44
+ "INSERT INTO raw_artifacts (kind, sha256, size_bytes, original_path, archive_path, imported_at)"
45
+ " VALUES (?,?,?,?,?,?)",
46
+ (kind, digest, path.stat().st_size, str(path), str(dest), utcnow()),
47
+ )
48
+ return cur.lastrowid, digest