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.
@@ -0,0 +1,303 @@
1
+ """Ingest Garmin Descent FIT activity files.
2
+
3
+ Decodes with the official garmin-fit-sdk. Known dive channels land in typed
4
+ columns of garmin_samples; every other field (named or unknown-numeric) is
5
+ preserved per sample in extra_json. Whole message types the FIT profile does
6
+ not know (233, 325, ...) are preserved per occurrence in undecoded_payloads.
7
+
8
+ An optional Garmin Connect index JSON (a list of activity dicts with
9
+ activityId, startTimeGMT, startTimeLocal, ...) supplies true UTC offsets and
10
+ Connect metadata; without it, times fall back to FIT timestamps.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import sqlite3
17
+ from datetime import datetime, timedelta, timezone
18
+ from pathlib import Path
19
+
20
+ from garmin_fit_sdk import Decoder, Stream
21
+
22
+ from ..store.db import source_dive_exists, utcnow
23
+ from .archive import archive_file
24
+
25
+ DECODER_VERSION = 1
26
+
27
+ FIT_EPOCH_OFFSET = 631065600 # FIT timestamps count from 1989-12-31 00:00 UTC
28
+ SEMICIRCLE = 180.0 / 2**31
29
+
30
+ # record message field -> garmin_samples column
31
+ RECORD_COLUMNS = {
32
+ "depth": "depth_m",
33
+ "temperature": "temp_c",
34
+ "heart_rate": "heart_rate",
35
+ "absolute_pressure": "absolute_pressure",
36
+ "next_stop_depth": "next_stop_depth_m",
37
+ "next_stop_time": "next_stop_time_s",
38
+ "time_to_surface": "tts_s",
39
+ "ndl_time": "ndl_s",
40
+ "cns_load": "cns_pct",
41
+ "n2_load": "n2_load",
42
+ "po2": "po2",
43
+ "ascent_rate": "ascent_rate",
44
+ "air_time_remaining": "air_time_remaining_s",
45
+ "pressure_sac": "pressure_sac",
46
+ "volume_sac": "volume_sac",
47
+ "rmv": "rmv",
48
+ }
49
+
50
+ # message types routed to dedicated tables rather than header_json
51
+ NON_HEADER_KEYS = {"record_mesgs", "event_mesgs", "dive_alarm_mesgs", "dive_gas_mesgs"}
52
+
53
+
54
+ def _jsonable(value):
55
+ if isinstance(value, (bytes, bytearray)):
56
+ return {"__hex__": bytes(value).hex()}
57
+ if isinstance(value, (list, tuple)):
58
+ return [_jsonable(v) for v in value]
59
+ if isinstance(value, dict):
60
+ return {str(k): _jsonable(v) for k, v in value.items()}
61
+ if isinstance(value, float) and value != value: # NaN
62
+ return None
63
+ return value
64
+
65
+
66
+ def load_index(index_path: Path | None) -> dict[str, dict]:
67
+ if not index_path or not index_path.exists():
68
+ return {}
69
+ entries = json.loads(index_path.read_text())
70
+ return {str(e["activityId"]): e for e in entries}
71
+
72
+
73
+ def _parse_connect_time(value: str) -> datetime:
74
+ return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
75
+
76
+
77
+ def ingest_fit_file(
78
+ con: sqlite3.Connection,
79
+ archive_dir: Path,
80
+ path: Path,
81
+ index: dict[str, dict],
82
+ ) -> str:
83
+ """Ingest one FIT file. Returns 'ingested' | 'skipped'."""
84
+ artifact_id, digest = archive_file(con, archive_dir, "fit", path)
85
+ if source_dive_exists(con, "garmin", digest):
86
+ return "skipped"
87
+
88
+ stream = Stream.from_file(str(path))
89
+ decoder = Decoder(stream)
90
+ messages, errors = decoder.read(convert_datetimes_to_dates=False)
91
+ if errors:
92
+ raise ValueError(f"FIT decode errors in {path.name}: {errors}")
93
+
94
+ records = messages.get("record_mesgs", [])
95
+ # A file can carry summaries but no sample stream (rare device hiccup);
96
+ # keep it as a header-only source dive rather than losing the dive.
97
+ if records:
98
+ t0 = records[0]["timestamp"]
99
+ else:
100
+ session = (messages.get("session_mesgs") or [{}])[0]
101
+ file_id_msg = (messages.get("file_id_mesgs") or [{}])[0]
102
+ t0 = session.get("start_time") or file_id_msg.get("time_created")
103
+ if t0 is None:
104
+ raise ValueError(f"no records and no start time in {path.name}")
105
+
106
+ file_id = (messages.get("file_id_mesgs") or [{}])[0]
107
+ activity_id = str(file_id.get("serial_number", ""))
108
+ # Garmin Connect's activityId is not in the file; recover it from the
109
+ # index by matching start time, else from a '<...>_<id>.fit' filename.
110
+ entry = None
111
+ stem_id = path.stem.rsplit("_", 1)[-1]
112
+ if stem_id.isdigit() and stem_id in index:
113
+ entry = index[stem_id]
114
+ activity_id = stem_id
115
+ else:
116
+ start_utc_probe = datetime.fromtimestamp(
117
+ t0 + FIT_EPOCH_OFFSET, tz=timezone.utc
118
+ ).strftime("%Y-%m-%d %H:%M:%S")
119
+ for aid, e in index.items():
120
+ if e.get("startTimeGMT") == start_utc_probe:
121
+ entry, activity_id = e, aid
122
+ break
123
+
124
+ start_dt_utc = datetime.fromtimestamp(t0 + FIT_EPOCH_OFFSET, tz=timezone.utc)
125
+ start_utc = start_dt_utc.strftime("%Y-%m-%d %H:%M:%S")
126
+ start_local, utc_offset_min = start_utc, None
127
+ if entry:
128
+ gmt = _parse_connect_time(entry["startTimeGMT"])
129
+ local = _parse_connect_time(entry["startTimeLocal"])
130
+ utc_offset_min = int((local - gmt).total_seconds() // 60)
131
+ start_local = entry["startTimeLocal"]
132
+ else:
133
+ # activity message carries the device's local timestamp
134
+ act = (messages.get("activity_mesgs") or [{}])[0]
135
+ if "local_timestamp" in act and "timestamp" in act:
136
+ utc_offset_min = int((act["local_timestamp"] - act["timestamp"]) // 60)
137
+ start_local = (
138
+ start_dt_utc + timedelta(minutes=utc_offset_min)
139
+ ).strftime("%Y-%m-%d %H:%M:%S")
140
+
141
+ header = {"connect_index": entry}
142
+ for key, msgs in messages.items():
143
+ if isinstance(key, str) and key.endswith("_mesgs") and key not in NON_HEADER_KEYS:
144
+ header[key] = _jsonable(msgs)
145
+
146
+ summaries = messages.get("dive_summary_mesgs") or []
147
+ session = (messages.get("session_mesgs") or [{}])[0]
148
+ duration = entry.get("duration") if entry else session.get("total_elapsed_time")
149
+ max_depth = max((r.get("depth") or 0 for r in records), default=None)
150
+ if max_depth is None and session.get("max_depth") is not None:
151
+ max_depth = session["max_depth"]
152
+ avg_depth = next(
153
+ (s.get("avg_depth") for s in summaries if s.get("avg_depth") is not None), None
154
+ )
155
+ mode = entry.get("activityType", {}).get("typeKey") if entry else None
156
+
157
+ intervals = [
158
+ records[i + 1]["timestamp"] - records[i]["timestamp"]
159
+ for i in range(min(len(records) - 1, 50))
160
+ ]
161
+ interval_ms = int(sorted(intervals)[len(intervals) // 2] * 1000) if intervals else None
162
+
163
+ cur = con.execute(
164
+ "INSERT INTO source_dives (source, artifact_id, source_key, content_hash,"
165
+ " decoder_version, start_time_utc, start_time_local, utc_offset_min,"
166
+ " duration_s, max_depth_m, avg_depth_m, sample_interval_ms, mode, header_json)"
167
+ " VALUES ('garmin',?,?,?,?,?,?,?,?,?,?,?,?,?)",
168
+ (
169
+ artifact_id,
170
+ activity_id,
171
+ digest,
172
+ DECODER_VERSION,
173
+ start_utc,
174
+ start_local,
175
+ utc_offset_min,
176
+ duration,
177
+ max_depth,
178
+ avg_depth,
179
+ interval_ms,
180
+ mode,
181
+ json.dumps(_jsonable(header), separators=(",", ":")),
182
+ ),
183
+ )
184
+ dive_id = cur.lastrowid
185
+
186
+ sample_rows = []
187
+ for rec in records:
188
+ row = {col: None for col in RECORD_COLUMNS.values()}
189
+ extra = {}
190
+ lat = lon = None
191
+ for k, v in rec.items():
192
+ if k == "timestamp":
193
+ continue
194
+ elif k == "position_lat":
195
+ lat = v * SEMICIRCLE
196
+ elif k == "position_long":
197
+ lon = v * SEMICIRCLE
198
+ elif isinstance(k, str) and k in RECORD_COLUMNS:
199
+ row[RECORD_COLUMNS[k]] = v
200
+ else:
201
+ extra[str(k)] = _jsonable(v)
202
+ sample_rows.append(
203
+ (
204
+ dive_id,
205
+ float(rec["timestamp"] - t0),
206
+ row["depth_m"], row["temp_c"], row["heart_rate"],
207
+ row["absolute_pressure"], lat, lon,
208
+ row["next_stop_depth_m"], row["next_stop_time_s"], row["tts_s"],
209
+ row["ndl_s"], row["cns_pct"], row["n2_load"], row["po2"],
210
+ row["ascent_rate"], row["air_time_remaining_s"],
211
+ row["pressure_sac"], row["volume_sac"], row["rmv"],
212
+ json.dumps(extra, separators=(",", ":")) if extra else None,
213
+ )
214
+ )
215
+ con.executemany(
216
+ "INSERT INTO garmin_samples (source_dive_id, t_s, depth_m, temp_c, heart_rate,"
217
+ " absolute_pressure, lat, lon, next_stop_depth_m, next_stop_time_s, tts_s,"
218
+ " ndl_s, cns_pct, n2_load, po2, ascent_rate, air_time_remaining_s,"
219
+ " pressure_sac, volume_sac, rmv, extra_json)"
220
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
221
+ sample_rows,
222
+ )
223
+
224
+ for gi, gas in enumerate(messages.get("dive_gas_mesgs") or []):
225
+ mode_str = str(gas.get("mode", ""))
226
+ circuit = {"open_circuit": "oc", "closed_circuit": "cc"}.get(mode_str)
227
+ con.execute(
228
+ "INSERT INTO gases (source_dive_id, gas_index, o2_pct, he_pct, circuit,"
229
+ " enabled, used, source_json) VALUES (?,?,?,?,?,?,?,?)",
230
+ (
231
+ dive_id,
232
+ gas.get("message_index", gi),
233
+ gas.get("oxygen_content"),
234
+ gas.get("helium_content"),
235
+ circuit,
236
+ 1 if gas.get("status") == "enabled" else 0,
237
+ None,
238
+ json.dumps(_jsonable(gas), separators=(",", ":")),
239
+ ),
240
+ )
241
+
242
+ event_rows = []
243
+ for kind, key in (("fit_event", "event_mesgs"), ("fit_dive_alarm", "dive_alarm_mesgs")):
244
+ for msg in messages.get(key) or []:
245
+ ts = msg.get("timestamp")
246
+ event_rows.append(
247
+ (
248
+ dive_id,
249
+ float(ts - t0) if isinstance(ts, (int, float)) else None,
250
+ kind,
251
+ json.dumps(_jsonable(msg), separators=(",", ":")),
252
+ )
253
+ )
254
+ con.executemany(
255
+ "INSERT INTO events (source_dive_id, t_s, kind, payload_json) VALUES (?,?,?,?)",
256
+ event_rows,
257
+ )
258
+
259
+ undecoded = []
260
+ for key, msgs in messages.items():
261
+ if isinstance(key, str) and key.endswith("_mesgs"):
262
+ continue
263
+ for seq, msg in enumerate(msgs):
264
+ undecoded.append(
265
+ (
266
+ dive_id,
267
+ "fit_mesg",
268
+ str(key),
269
+ seq,
270
+ None,
271
+ json.dumps(_jsonable(msg), separators=(",", ":")),
272
+ )
273
+ )
274
+ con.executemany(
275
+ "INSERT INTO undecoded_payloads (source_dive_id, container, type_key, seq,"
276
+ " payload, fields_json) VALUES (?,?,?,?,?,?)",
277
+ undecoded,
278
+ )
279
+
280
+ return "ingested"
281
+
282
+
283
+ def ingest_garmin_dir(
284
+ con: sqlite3.Connection,
285
+ archive_dir: Path,
286
+ fit_dir: Path,
287
+ index_name: str = "dives_index.json",
288
+ ) -> dict:
289
+ index_path = fit_dir / index_name
290
+ index = load_index(index_path)
291
+ if index_path.exists():
292
+ archive_file(con, archive_dir, "garmin_index", index_path)
293
+
294
+ stats = {"ingested": 0, "skipped": 0, "failed": 0}
295
+ for path in sorted(fit_dir.glob("*.fit")):
296
+ try:
297
+ with con:
298
+ result = ingest_fit_file(con, archive_dir, path, index)
299
+ stats[result] += 1
300
+ except Exception as e:
301
+ stats["failed"] += 1
302
+ print(f"FAILED {path.name}: {type(e).__name__}: {e}")
303
+ return stats
@@ -0,0 +1,231 @@
1
+ """Ingest a Shearwater Cloud database (dive_data.db / 'Export Database').
2
+
3
+ Each dive's complete native log lives in log_data.data_bytes_1 as an sw-pnf
4
+ blob (decoded by bottomtime.decode.pnf); dive_details supplies the metadata
5
+ layer (gas plan JSON, EndGF99, site/notes when present). The blob is stored
6
+ verbatim in source_dives.raw_blob in addition to the archived .db, so every
7
+ Shearwater byte is preserved twice over.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import sqlite3
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+ from ..decode import pnf
19
+ from ..store.db import source_dive_exists
20
+ from .archive import archive_file
21
+
22
+
23
+ def _details_row(con: sqlite3.Connection, dive_id: str) -> dict:
24
+ row = con.execute(
25
+ "SELECT * FROM dive_details WHERE DiveId = ?", (dive_id,)
26
+ ).fetchone()
27
+ if row is None:
28
+ return {}
29
+ return {k: row[k] for k in row.keys()}
30
+
31
+
32
+ def ingest_shearwater_db(
33
+ con: sqlite3.Connection, archive_dir: Path, db_path: Path
34
+ ) -> dict:
35
+ archive_file(con, archive_dir, "shearwater_db", db_path)
36
+
37
+ src = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
38
+ src.row_factory = sqlite3.Row
39
+
40
+ stats = {"ingested": 0, "skipped": 0, "failed": 0}
41
+ rows = src.execute(
42
+ "SELECT log_id, file_name, format, format_version,"
43
+ " calculated_values_from_samples, data_bytes_1, data_bytes_2, data_bytes_3"
44
+ " FROM log_data"
45
+ ).fetchall()
46
+
47
+ artifact_id = con.execute(
48
+ "SELECT id FROM raw_artifacts WHERE kind='shearwater_db' AND original_path=?"
49
+ " ORDER BY id DESC LIMIT 1",
50
+ (str(db_path),),
51
+ ).fetchone()[0]
52
+
53
+ for row in rows:
54
+ try:
55
+ with con:
56
+ result = _ingest_log(con, artifact_id, src, row)
57
+ stats[result] += 1
58
+ except Exception as e:
59
+ stats["failed"] += 1
60
+ print(f"FAILED {row['file_name']}: {type(e).__name__}: {e}")
61
+
62
+ src.close()
63
+ return stats
64
+
65
+
66
+ def _ingest_log(
67
+ con: sqlite3.Connection,
68
+ artifact_id: int,
69
+ src: sqlite3.Connection,
70
+ row: sqlite3.Row,
71
+ ) -> str:
72
+ if row["format"] != "sw-pnf":
73
+ raise ValueError(f"unsupported log format {row['format']!r}")
74
+
75
+ blob = row["data_bytes_1"]
76
+ digest = hashlib.sha256(blob).hexdigest()
77
+ if source_dive_exists(con, "shearwater", digest):
78
+ return "skipped"
79
+
80
+ dive = pnf.decode(blob)
81
+ details = _details_row(src, row["log_id"])
82
+ b2 = json.loads(row["data_bytes_2"]) if row["data_bytes_2"] else {}
83
+ b3 = json.loads(row["data_bytes_3"]) if row["data_bytes_3"] else {}
84
+ calc = (
85
+ json.loads(row["calculated_values_from_samples"])
86
+ if row["calculated_values_from_samples"]
87
+ else {}
88
+ )
89
+
90
+ serial = details.get("SerialNumber")
91
+ if not serial and "computer_serial" in dive.header:
92
+ serial = f"{dive.header['computer_serial']:08X}"
93
+ dive_number = details.get("DiveNumber") or b3.get("DiveNumber")
94
+ source_key = f"{serial}#{dive_number}"
95
+
96
+ # DIVE_START_TIME is the local wall clock stored as a fake-UTC epoch.
97
+ start_epoch = b2.get("DIVE_START_TIME") or b3.get("StartTime")
98
+ start_local = datetime.fromtimestamp(start_epoch, tz=timezone.utc).strftime(
99
+ "%Y-%m-%d %H:%M:%S"
100
+ )
101
+
102
+ header = {
103
+ "pnf": dive.header,
104
+ "log_data": {
105
+ "log_id": row["log_id"],
106
+ "file_name": row["file_name"],
107
+ "format": row["format"],
108
+ "format_version": row["format_version"],
109
+ "data_bytes_2": b2,
110
+ "data_bytes_3": b3,
111
+ "calculated_values_from_samples": calc,
112
+ },
113
+ "dive_details": details,
114
+ }
115
+
116
+ cur = con.execute(
117
+ "INSERT INTO source_dives (source, artifact_id, source_key, content_hash,"
118
+ " decoder_version, start_time_utc, start_time_local, utc_offset_min,"
119
+ " duration_s, max_depth_m, avg_depth_m, sample_interval_ms, mode,"
120
+ " header_json, raw_blob)"
121
+ " VALUES ('shearwater',?,?,?,?,NULL,?,NULL,?,?,?,?,?,?,?)",
122
+ (
123
+ artifact_id,
124
+ source_key,
125
+ digest,
126
+ pnf.DECODER_VERSION,
127
+ start_local,
128
+ dive.header.get("divetime_s"),
129
+ dive.header.get("max_depth_m"),
130
+ calc.get("AverageDepth"),
131
+ dive.sample_interval_ms,
132
+ dive.header.get("divemode"),
133
+ json.dumps(header, separators=(",", ":")),
134
+ blob,
135
+ ),
136
+ )
137
+ dive_id = cur.lastrowid
138
+
139
+ con.executemany(
140
+ "INSERT INTO shearwater_samples (source_dive_id, t_s, depth_m, temp_c,"
141
+ " stop_depth_m, stop_or_ndl_min, tts_min, in_deco, avg_ppo2, o2_pct, he_pct,"
142
+ " setpoint, cns_pct, gf99, ceiling_m, sensor1_raw, sensor2_raw, sensor3_raw,"
143
+ " sensor1_ppo2, sensor2_ppo2, sensor3_ppo2, battery_v, status_flags,"
144
+ " tank0_psi, tank1_psi, gas_time_min, extra_json)"
145
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
146
+ [
147
+ (
148
+ dive_id, s.t_s, s.depth_m, s.temp_c,
149
+ s.stop_depth_m, s.stop_or_ndl_min, s.tts_min, s.in_deco,
150
+ s.avg_ppo2, s.o2_pct, s.he_pct,
151
+ s.setpoint, s.cns_pct, s.gf99, s.ceiling_m,
152
+ s.sensor1_raw, s.sensor2_raw, s.sensor3_raw,
153
+ s.sensor1_ppo2, s.sensor2_ppo2, s.sensor3_ppo2,
154
+ s.battery_v, s.status_flags,
155
+ s.tank0_psi, s.tank1_psi, s.gas_time_min,
156
+ json.dumps(s.extra, separators=(",", ":")) if s.extra else None,
157
+ )
158
+ for s in dive.samples
159
+ ],
160
+ )
161
+
162
+ for gas in dive.gases:
163
+ if gas.o2_pct == 0 and gas.he_pct == 0:
164
+ continue
165
+ con.execute(
166
+ "INSERT INTO gases (source_dive_id, gas_index, o2_pct, he_pct, circuit,"
167
+ " enabled, used, source_json) VALUES (?,?,?,?,?,?,NULL,NULL)",
168
+ (
169
+ dive_id,
170
+ gas.index,
171
+ gas.o2_pct,
172
+ gas.he_pct,
173
+ "cc" if gas.diluent else "oc",
174
+ 1 if gas.enabled else 0,
175
+ ),
176
+ )
177
+
178
+ tank_profile = details.get("TankProfileData")
179
+ if tank_profile:
180
+ try:
181
+ profile = json.loads(tank_profile)
182
+ except (TypeError, ValueError):
183
+ profile = None
184
+ for seg in (profile or {}).get("GasProfiles") or []:
185
+ con.execute(
186
+ "INSERT INTO gas_segments (source_dive_id, start_s, end_s, o2_pct,"
187
+ " he_pct, circuit, avg_depth_m, source_json) VALUES (?,?,?,?,?,?,?,?)",
188
+ (
189
+ dive_id,
190
+ seg.get("StartTimeInSeconds"),
191
+ seg.get("EndTimeInSeconds"),
192
+ seg.get("O2Percent"),
193
+ seg.get("HePercent"),
194
+ seg.get("CircuitMode"),
195
+ seg.get("AverageDepthInMeters"),
196
+ json.dumps(seg, separators=(",", ":")),
197
+ ),
198
+ )
199
+
200
+ con.executemany(
201
+ "INSERT INTO events (source_dive_id, t_s, kind, payload_json) VALUES (?,?,?,?)",
202
+ [
203
+ (
204
+ dive_id,
205
+ e.t_s,
206
+ "pnf_info_event",
207
+ json.dumps(
208
+ {
209
+ "event": e.event,
210
+ "timestamp": e.timestamp,
211
+ "w1": e.w1,
212
+ "w2": e.w2,
213
+ "raw_hex": e.raw_hex,
214
+ },
215
+ separators=(",", ":"),
216
+ ),
217
+ )
218
+ for e in dive.events
219
+ ],
220
+ )
221
+
222
+ con.executemany(
223
+ "INSERT INTO undecoded_payloads (source_dive_id, container, type_key, seq,"
224
+ " payload, fields_json) VALUES (?,?,?,?,?,NULL)",
225
+ [
226
+ (dive_id, "pnf_record", type_key, seq, raw)
227
+ for type_key, seq, raw in dive.unknown_records
228
+ ],
229
+ )
230
+
231
+ return "ingested"
@@ -0,0 +1,92 @@
1
+ """Parse Shearwater Cloud 'Shearwater XML' exports.
2
+
3
+ Used as an independent cross-check of the PNF decoder (verify), not as a
4
+ third sample series: the XML is a lossy rendering of the same native log.
5
+ Files declare encoding="utf-16" but are written as ASCII; parse accordingly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import xml.etree.ElementTree as ET
12
+ from pathlib import Path
13
+
14
+ # AI columns carry text sentinels when no transmitter is paired
15
+ _TEXT_SENTINELS = {"AI is off", "N/A", "Not paired", "No comms", ""}
16
+
17
+ _NUMERIC_SAMPLE_FIELDS = (
18
+ "currentTime",
19
+ "currentDepth",
20
+ "firstStopDepth",
21
+ "firstStopTime",
22
+ "ttsMins",
23
+ "averagePPO2",
24
+ "fractionO2",
25
+ "fractionHe",
26
+ "currentNdl",
27
+ "waterTemp",
28
+ "batteryVoltage",
29
+ "sensor1Millivolts",
30
+ "sensor2Millivolts",
31
+ "sensor3Millivolts",
32
+ "sac",
33
+ "sad",
34
+ )
35
+
36
+
37
+ def _num(text: str | None) -> float | None:
38
+ if text is None or text.strip() in _TEXT_SENTINELS:
39
+ return None
40
+ try:
41
+ return float(text)
42
+ except ValueError:
43
+ return None
44
+
45
+
46
+ def parse_xml(path: Path) -> dict:
47
+ text = path.read_text(encoding="ascii", errors="replace")
48
+ text = re.sub(r"^<\?xml[^>]*\?>", "", text, count=1)
49
+ root = ET.fromstring(text)
50
+ log = root.find("diveLog")
51
+
52
+ header = {
53
+ el.tag: (el.text or "").strip()
54
+ for el in log
55
+ if el.tag != "diveLogRecords" and len(el) == 0
56
+ }
57
+
58
+ samples = []
59
+ for rec in log.find("diveLogRecords") or []:
60
+ sample = {}
61
+ for el in rec:
62
+ if el.tag in _NUMERIC_SAMPLE_FIELDS:
63
+ sample[el.tag] = _num(el.text)
64
+ else:
65
+ sample[el.tag] = (el.text or "").strip()
66
+ samples.append(sample)
67
+
68
+ return {"path": str(path), "header": header, "samples": samples}
69
+
70
+
71
+ def normalize_start(value: str) -> str:
72
+ """Normalize a Shearwater startDate to 'YYYY-MM-DD HH:MM:SS'.
73
+
74
+ Accepts the export's native 'dd-MM-yyyy HH:mm:ss' as well as ISO
75
+ 'yyyy-MM-ddTHH:mm:ss' (files repaired for Garmin Connect upload)."""
76
+ value = value.strip().replace("T", " ")
77
+ m = re.match(r"^(\d{2})-(\d{2})-(\d{4}) (\d{2}:\d{2}:\d{2})$", value)
78
+ if m:
79
+ return f"{m.group(3)}-{m.group(2)}-{m.group(1)} {m.group(4)}"
80
+ return value
81
+
82
+
83
+ def load_xml_dir(xml_dir: Path) -> dict[str, dict]:
84
+ """Map normalized start wall-clock -> parsed XML for every export.
85
+
86
+ Keyed by start time, not dive number: the on-unit dive counter can be
87
+ reset, so numbers repeat across a collection."""
88
+ dives = {}
89
+ for path in sorted(xml_dir.glob("*.xml")):
90
+ parsed = parse_xml(path)
91
+ dives[normalize_start(parsed["header"]["startDate"])] = parsed
92
+ return dives
@@ -0,0 +1 @@
1
+