pybabytracker 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,134 @@
1
+ """Async client for the Nighp Baby Tracker sync service."""
2
+
3
+ from ._version import __version__
4
+ from .client import BabyTrackerClient, Device, RawTransaction
5
+ from .engine import ApplyResult, SyncEngine, SyncResult, SyncSource
6
+ from .exceptions import (
7
+ AuthenticationError,
8
+ BabyTrackerConnectionError,
9
+ BabyTrackerError,
10
+ NoResponseError,
11
+ NotOwnRecordError,
12
+ NotSyncedError,
13
+ ObjectIdInUseError,
14
+ ProtocolError,
15
+ RecordNotFoundError,
16
+ TimerNotRunningError,
17
+ TransactionDecodeError,
18
+ UnknownBabyError,
19
+ WriteError,
20
+ WriteOutcomeUnknownError,
21
+ WrongRecordTypeError,
22
+ )
23
+ from .models import (
24
+ DIAPER_DIRTY,
25
+ DIAPER_MIXED,
26
+ DIAPER_WET,
27
+ OP_ADD,
28
+ OP_DELETE,
29
+ OP_UPDATE,
30
+ RECORD_TYPES,
31
+ SIDE_LEFT,
32
+ SIDE_RIGHT,
33
+ UNIT_MILLILITERS,
34
+ UNIT_OUNCES,
35
+ Allergen,
36
+ Baby,
37
+ Bath,
38
+ Diaper,
39
+ DoctorVisit,
40
+ Formula,
41
+ Growth,
42
+ HealthQuestion,
43
+ Journal,
44
+ Joy,
45
+ Medicine,
46
+ Milestone,
47
+ Nursing,
48
+ OtherActivity,
49
+ OtherFeed,
50
+ Pump,
51
+ Pumped,
52
+ Record,
53
+ Sick,
54
+ Sleep,
55
+ Temperature,
56
+ UnknownRecord,
57
+ Vaccine,
58
+ Volume,
59
+ VolumeMeasure,
60
+ decode_payload,
61
+ decode_transaction,
62
+ encode_transaction,
63
+ )
64
+ from .times import format_time, from_unix, parse_time, standard_offset_seconds, to_unix
65
+
66
+ __all__ = [
67
+ "Allergen",
68
+ "ApplyResult",
69
+ "AuthenticationError",
70
+ "Baby",
71
+ "BabyTrackerClient",
72
+ "BabyTrackerConnectionError",
73
+ "BabyTrackerError",
74
+ "Bath",
75
+ "DIAPER_DIRTY",
76
+ "DIAPER_MIXED",
77
+ "DIAPER_WET",
78
+ "Device",
79
+ "Diaper",
80
+ "DoctorVisit",
81
+ "Formula",
82
+ "Growth",
83
+ "HealthQuestion",
84
+ "Journal",
85
+ "Joy",
86
+ "Medicine",
87
+ "Milestone",
88
+ "NoResponseError",
89
+ "NotOwnRecordError",
90
+ "NotSyncedError",
91
+ "ObjectIdInUseError",
92
+ "Nursing",
93
+ "OP_ADD",
94
+ "OP_DELETE",
95
+ "OP_UPDATE",
96
+ "OtherActivity",
97
+ "OtherFeed",
98
+ "ProtocolError",
99
+ "Pump",
100
+ "Pumped",
101
+ "RECORD_TYPES",
102
+ "SIDE_LEFT",
103
+ "SIDE_RIGHT",
104
+ "UNIT_MILLILITERS",
105
+ "UNIT_OUNCES",
106
+ "RawTransaction",
107
+ "RecordNotFoundError",
108
+ "Record",
109
+ "Sick",
110
+ "Sleep",
111
+ "SyncEngine",
112
+ "SyncResult",
113
+ "SyncSource",
114
+ "Temperature",
115
+ "TimerNotRunningError",
116
+ "TransactionDecodeError",
117
+ "UnknownBabyError",
118
+ "UnknownRecord",
119
+ "Vaccine",
120
+ "Volume",
121
+ "VolumeMeasure",
122
+ "WriteError",
123
+ "WriteOutcomeUnknownError",
124
+ "WrongRecordTypeError",
125
+ "__version__",
126
+ "decode_payload",
127
+ "decode_transaction",
128
+ "encode_transaction",
129
+ "format_time",
130
+ "from_unix",
131
+ "parse_time",
132
+ "standard_offset_seconds",
133
+ "to_unix",
134
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,415 @@
1
+ """Builders for new records, in the shape the app writes them.
2
+
3
+ Each ``draft_*`` function checks its input at once (so a bad call is refused before the engine
4
+ makes any request) and returns a ``Draft``: everything about the record except the ID, the
5
+ baby, and the write timestamp, which the engine fills in under its lock. The payload shapes
6
+ follow the iPad capture (``tests/fixtures/captured_payloads.json``) key for key.
7
+
8
+ Conventions:
9
+
10
+ - ``time`` is when the activity started (a nursing session, a sleep, a pump) or when it
11
+ happened (a bottle, a diaper). It's written in UTC. ``timezone`` is seconds east of UTC,
12
+ defaulting to the offset of the time given. The app itself writes the zone's *standard*
13
+ offset all year, ignoring daylight saving (observed 2026-10-26), so pass that to match it:
14
+ ``standard_offset_seconds(tz, at)`` computes it. The default is the time's actual offset,
15
+ which in summer differs from the app's by an hour.
16
+ - Volumes are ``Volume(value, unit)`` with unit ``"oz"`` or ``"ml"``, written as a
17
+ ``VolumeMeasure``: ``englishMeasure`` "true" for ounces, "false" for milliliters, with the
18
+ number as given and no conversion. Both forms were captured from the app (ounces and
19
+ milliliters on 2026-10-26). Whole numbers are written as integers, like the capture.
20
+ - Durations are whole minutes. A sleep's is its length rounded half up (the capture stored about
21
+ 30 seconds as 1 minute), and never less than 1 for a finished sleep, because 0 means a sleep
22
+ in progress. Nursing and pump durations are given in whole minutes already.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import math
28
+ from collections.abc import Callable
29
+ from dataclasses import dataclass
30
+ from datetime import UTC, datetime, timedelta
31
+ from typing import Any
32
+
33
+ from .models import (
34
+ DIAPER_DIRTY,
35
+ DIAPER_MIXED,
36
+ DIAPER_WET,
37
+ SIDE_LEFT,
38
+ SIDE_RIGHT,
39
+ UNIT_MILLILITERS,
40
+ UNIT_OUNCES,
41
+ Baby,
42
+ Diaper,
43
+ Formula,
44
+ Nursing,
45
+ Pump,
46
+ Pumped,
47
+ Record,
48
+ Sleep,
49
+ Volume,
50
+ VolumeMeasure,
51
+ )
52
+ from .times import format_time
53
+
54
+ MAX_MINUTES_PER_SIDE = 240
55
+ MAX_SLEEP = timedelta(hours=24)
56
+ MAX_SLEEP_MINUTES = 24 * 60
57
+ # How far ahead of this clock a timer's start may be: phones and Home Assistant disagree a little.
58
+ START_SKEW = timedelta(minutes=1)
59
+
60
+
61
+ # What the app filled in on the captured nursing add: a finished, detailed session.
62
+ _NURSING_FINISHED_STATE = 5
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class Draft[R: Record]:
67
+ """A validated record waiting for an ID, a baby, and a write timestamp."""
68
+
69
+ record_type: type[R]
70
+ _build: Callable[[str, Baby | None, datetime], R]
71
+
72
+ def build(self, object_id: str, baby: Baby | None, timestamp: datetime) -> R:
73
+ return self._build(object_id, baby, timestamp)
74
+
75
+
76
+ def _common(
77
+ time: datetime | None, timezone_offset: int | None, note: str
78
+ ) -> tuple[datetime, int, str]:
79
+ """The event time in UTC (to the second), its offset, and the note."""
80
+ event = time if time is not None else datetime.now().astimezone()
81
+ offset = _offset(event, "time")
82
+ return (
83
+ event.astimezone(UTC).replace(microsecond=0),
84
+ timezone_offset if timezone_offset is not None else offset,
85
+ note,
86
+ )
87
+
88
+
89
+ def _offset(moment: datetime, name: str) -> int:
90
+ offset = moment.utcoffset()
91
+ if offset is None:
92
+ raise ValueError(f"{name} must be timezone-aware")
93
+ return int(offset.total_seconds())
94
+
95
+
96
+ def _number(value: float) -> int | float:
97
+ """A volume as the app writes it: rounded to 2 decimals, an integer when whole."""
98
+ rounded = round(float(value), 2)
99
+ return int(rounded) if rounded.is_integer() else rounded
100
+
101
+
102
+ def _checked(volume: object, name: str = "volume") -> Volume:
103
+ if not isinstance(volume, Volume):
104
+ raise ValueError(f"{name} must be a Volume(value, unit)")
105
+ if volume.unit not in (UNIT_OUNCES, UNIT_MILLILITERS):
106
+ raise ValueError(f"unknown {name} unit {volume.unit!r}: use 'oz' or 'ml'")
107
+ value = volume.value
108
+ if isinstance(value, bool) or not isinstance(value, int | float) or not math.isfinite(value):
109
+ raise ValueError(f"{name} must be a finite number")
110
+ if value < 0:
111
+ raise ValueError(f"{name} can't be negative")
112
+ return Volume(_number(value), volume.unit)
113
+
114
+
115
+ def _measure(volume: Volume) -> VolumeMeasure:
116
+ return VolumeMeasure(_number(volume.value), "true" if volume.unit == UNIT_OUNCES else "false")
117
+
118
+
119
+ def _minutes(value: object, name: str) -> int:
120
+ if isinstance(value, bool) or not isinstance(value, int):
121
+ raise ValueError(f"{name} must be a whole number of minutes")
122
+ if value < 0:
123
+ raise ValueError(f"{name} can't be negative")
124
+ if value > MAX_MINUTES_PER_SIDE:
125
+ raise ValueError(f"{name} can't be more than {MAX_MINUTES_PER_SIDE}")
126
+ return value
127
+
128
+
129
+ def _side(finish_side: int | None, left_used: bool, right_used: bool) -> int:
130
+ """The side finished on: as given, else the last side used (right when both were)."""
131
+ if finish_side is not None:
132
+ if finish_side not in (SIDE_LEFT, SIDE_RIGHT):
133
+ raise ValueError(f"finish_side must be SIDE_LEFT or SIDE_RIGHT, not {finish_side!r}")
134
+ return finish_side
135
+ return SIDE_LEFT if left_used and not right_used else SIDE_RIGHT
136
+
137
+
138
+ def _base(
139
+ object_id: str, baby: Baby | None, timestamp: datetime, event: datetime, tz: int, text: str
140
+ ) -> dict[str, Any]:
141
+ """The fields every new record carries; ``baby`` is left out when there is none."""
142
+ common: dict[str, Any] = {
143
+ "object_id": object_id,
144
+ "time": event,
145
+ "timestamp": timestamp,
146
+ "timezone": tz,
147
+ "note": text,
148
+ "picture_note": [],
149
+ "new_flag": "true",
150
+ "picture_loaded": "true",
151
+ }
152
+ if baby is not None:
153
+ common["baby"] = baby
154
+ return common
155
+
156
+
157
+ def draft_diaper(
158
+ status: int, *, time: datetime | None, timezone_offset: int | None, note: str
159
+ ) -> Draft[Diaper]:
160
+ if status not in (DIAPER_WET, DIAPER_DIRTY, DIAPER_MIXED):
161
+ raise ValueError(f"unknown diaper status: {status!r}")
162
+ event, tz, text = _common(time, timezone_offset, note)
163
+
164
+ def build(object_id: str, baby: Baby | None, timestamp: datetime) -> Diaper:
165
+ return Diaper(
166
+ **_base(object_id, baby, timestamp, event, tz, text),
167
+ status=status,
168
+ # What the app filled in on the captured add (PROTOCOL.md) with only a type set.
169
+ amount=2,
170
+ pee_color=5,
171
+ poo_color=5,
172
+ texture=5,
173
+ flag=0,
174
+ )
175
+
176
+ return Draft(Diaper, build)
177
+
178
+
179
+ def draft_bottle[R: (Formula, Pumped)](
180
+ record_type: type[R],
181
+ volume: Volume,
182
+ *,
183
+ time: datetime | None,
184
+ timezone_offset: int | None,
185
+ note: str,
186
+ ) -> Draft[R]:
187
+ """A formula or pumped-milk bottle."""
188
+ checked = _checked(volume)
189
+ if checked.value <= 0:
190
+ raise ValueError("a bottle's volume must be more than zero") # also after rounding
191
+ event, tz, text = _common(time, timezone_offset, note)
192
+
193
+ def build(object_id: str, baby: Baby | None, timestamp: datetime) -> R:
194
+ return record_type(
195
+ **_base(object_id, baby, timestamp, event, tz, text),
196
+ amount=_measure(checked),
197
+ )
198
+
199
+ return Draft(record_type, build)
200
+
201
+
202
+ def draft_nursing(
203
+ *,
204
+ left_minutes: int,
205
+ right_minutes: int,
206
+ finish_side: int | None,
207
+ start_time: datetime | None,
208
+ end_time: datetime | None,
209
+ timezone_offset: int | None,
210
+ note: str,
211
+ ) -> Draft[Nursing]:
212
+ """A finished nursing session. ``time`` is its start: ``start_time``, or ``end_time`` minus
213
+ the total minutes, or (given neither) now minus the total minutes."""
214
+ left = _minutes(left_minutes, "left_minutes")
215
+ right = _minutes(right_minutes, "right_minutes")
216
+ if left + right == 0:
217
+ raise ValueError("a nursing session needs at least one minute on a side")
218
+ if start_time is not None and end_time is not None:
219
+ raise ValueError("give start_time or end_time, not both")
220
+ side = _side(finish_side, left > 0, right > 0)
221
+ total = timedelta(minutes=left + right)
222
+ if start_time is not None:
223
+ _offset(start_time, "start_time")
224
+ start = start_time
225
+ else:
226
+ end = end_time if end_time is not None else datetime.now().astimezone()
227
+ _offset(end, "end_time")
228
+ start = end - total
229
+ event, tz, text = _common(start, timezone_offset, note)
230
+
231
+ def build(object_id: str, baby: Baby | None, timestamp: datetime) -> Nursing:
232
+ return Nursing(
233
+ **_base(object_id, baby, timestamp, event, tz, text),
234
+ left_duration=left,
235
+ right_duration=right,
236
+ both_duration=0,
237
+ finish_side=side,
238
+ is_detailed="true",
239
+ pair_session_state=0,
240
+ state=_NURSING_FINISHED_STATE,
241
+ )
242
+
243
+ return Draft(Nursing, build)
244
+
245
+
246
+ def draft_sleep(
247
+ *, start: datetime, end: datetime, timezone_offset: int | None, note: str
248
+ ) -> Draft[Sleep]:
249
+ """A finished sleep from ``start`` to ``end``."""
250
+ _offset(start, "start")
251
+ _offset(end, "end")
252
+ if end <= start:
253
+ raise ValueError("end must be after start")
254
+ if end - start > MAX_SLEEP:
255
+ raise ValueError("a sleep can't be longer than 24 hours")
256
+ minutes = sleep_minutes(start, end)
257
+ event, tz, text = _common(start, timezone_offset, note)
258
+
259
+ def build(object_id: str, baby: Baby | None, timestamp: datetime) -> Sleep:
260
+ return Sleep(
261
+ **_base(object_id, baby, timestamp, event, tz, text),
262
+ duration=minutes,
263
+ )
264
+
265
+ return Draft(Sleep, build)
266
+
267
+
268
+ def sleep_minutes(start: datetime, end: datetime) -> int:
269
+ """A finished sleep's duration: whole minutes rounded half up, at least 1 (0 means running).
270
+ The caller checks that ``end`` is after ``start``."""
271
+ seconds = (end - start).total_seconds()
272
+ return max(1, math.floor(seconds / 60 + 0.5))
273
+
274
+
275
+ def draft_sleep_start(
276
+ *, start: datetime | None, timezone_offset: int | None, note: str, now: datetime | None = None
277
+ ) -> Draft[Sleep]:
278
+ """A running sleep: ``duration`` 0, as the app posts at a timer's start. ``start`` defaults to
279
+ now; it can't be in the future (beyond a minute of clock skew) or more than 24 hours ago,
280
+ since such a sleep could never be ended inside the cap."""
281
+ current = now if now is not None else datetime.now(UTC)
282
+ begin = start if start is not None else current
283
+ _offset(begin, "start")
284
+ if begin - current > START_SKEW:
285
+ raise ValueError("a sleep can't start in the future")
286
+ if current - begin > MAX_SLEEP:
287
+ raise ValueError("a sleep can't have started more than 24 hours ago")
288
+ event, tz, text = _common(begin, timezone_offset, note)
289
+
290
+ def build(object_id: str, baby: Baby | None, timestamp: datetime) -> Sleep:
291
+ return Sleep(
292
+ **_base(object_id, baby, timestamp, event, tz, text),
293
+ duration=0,
294
+ )
295
+
296
+ return Draft(Sleep, build)
297
+
298
+
299
+ @dataclass(frozen=True, slots=True)
300
+ class SleepEnd:
301
+ """How a running sleep ends: the minutes to write, and what was adjusted to get them."""
302
+
303
+ minutes: int
304
+ end: datetime
305
+ future_clamped: bool = False # ``end`` was more than START_SKEW ahead, so now was used
306
+ long_clamped: bool = False # longer than 24 hours, so 1,440 minutes are written
307
+
308
+
309
+ def resolve_sleep_end(sleep: Sleep, end: datetime, now: datetime) -> SleepEnd:
310
+ """Check that ``end`` can end ``sleep`` at ``now``, and work out the duration to write.
311
+
312
+ - ``end`` must be aware. One more than ``START_SKEW`` ahead of ``now`` is clamped to ``now``.
313
+ - An end at or before the start, by up to ``START_SKEW``, is a 1-minute sleep: a start saved
314
+ by a clock running a little fast (a phone's, or this one's within ``start_sleep``'s skew)
315
+ can always be stopped. An end further before the start raises ``ValueError``.
316
+ - Otherwise ``sleep_minutes``. A sleep longer than 24 hours is clamped to 1,440 minutes
317
+ rather than refused, so a timer someone forgot can always be stopped; the length can be
318
+ fixed in the app afterward.
319
+ """
320
+ _offset(end, "end")
321
+ start = sleep.time
322
+ if start is None:
323
+ raise ValueError(f"sleep {sleep.object_id} has no start time")
324
+ future = end - now > START_SKEW
325
+ if future:
326
+ end = now
327
+ if end <= start:
328
+ if start - end > START_SKEW:
329
+ raise ValueError("end must be after the sleep's start")
330
+ return SleepEnd(1, end, future_clamped=future)
331
+ if end - start > MAX_SLEEP:
332
+ return SleepEnd(MAX_SLEEP_MINUTES, end, future_clamped=future, long_clamped=True)
333
+ return SleepEnd(sleep_minutes(start, end), end, future_clamped=future)
334
+
335
+
336
+ def ended_sleep_payload(sleep: Sleep, minutes: int, timestamp: datetime) -> dict[str, Any]:
337
+ """The update that ends a running sleep, as the app posts it at a timer's stop: the record as
338
+ last saved (key order and unknown keys kept), with only ``duration``, ``newFlage`` "false",
339
+ and ``timestamp`` changed."""
340
+ payload = sleep.to_payload()
341
+ payload["duration"] = minutes
342
+ payload["newFlage"] = "false"
343
+ payload["timestamp"] = format_time(timestamp.astimezone(UTC))
344
+ return payload
345
+
346
+
347
+ def draft_pump(
348
+ *,
349
+ amount: Volume | None,
350
+ left_volume: Volume | None,
351
+ right_volume: Volume | None,
352
+ left_minutes: int,
353
+ right_minutes: int,
354
+ finish_side: int | None,
355
+ time: datetime | None,
356
+ timezone_offset: int | None,
357
+ note: str,
358
+ default_unit: str = UNIT_OUNCES,
359
+ ) -> Draft[Pump]:
360
+ """A pumping session, which belongs to the account (no baby). Give the total as ``amount``,
361
+ or the volume of one or both sides. With only a total the payload has no per-side amounts,
362
+ like the app's own pump; with sides, ``amount`` is their sum and a side left out is written
363
+ as 0 in the other side's unit. With no volume at all, the session needs a length: the payload
364
+ then has the durations and an ``amount`` of 0 in ``default_unit``, as the app writes a timed
365
+ pump whose volume wasn't entered (never a missing key).
366
+ ``time`` is the session's start."""
367
+ if amount is not None and (left_volume is not None or right_volume is not None):
368
+ raise ValueError("give a pump's total volume or its side volumes, not both")
369
+ left = _minutes(left_minutes, "left_minutes")
370
+ right = _minutes(right_minutes, "right_minutes")
371
+ if amount is None and left_volume is None and right_volume is None and left + right == 0:
372
+ raise ValueError("a pump needs a volume for at least one side, or a length")
373
+ if default_unit not in (UNIT_OUNCES, UNIT_MILLILITERS):
374
+ raise ValueError(f"unknown volume unit {default_unit!r}")
375
+ per_side = amount is None and (left_volume is not None or right_volume is not None)
376
+ unit: str = default_unit
377
+ left_amount: int | float = 0
378
+ right_amount: int | float = 0
379
+ total: int | float = 0
380
+ if amount is not None:
381
+ total_v = _checked(amount, "amount")
382
+ unit = total_v.unit
383
+ total = total_v.value
384
+ elif per_side:
385
+ left_v = _checked(left_volume, "left_volume") if left_volume is not None else None
386
+ right_v = _checked(right_volume, "right_volume") if right_volume is not None else None
387
+ units = {v.unit for v in (left_v, right_v) if v is not None}
388
+ if len(units) != 1:
389
+ raise ValueError("both sides of a pump must use the same unit")
390
+ unit = units.pop()
391
+ left_amount = left_v.value if left_v is not None else 0
392
+ right_amount = right_v.value if right_v is not None else 0
393
+ total = round(left_amount + right_amount, 6)
394
+ if (amount is not None or per_side) and total <= 0:
395
+ raise ValueError("a pump's total volume must be more than zero")
396
+ side = _side(finish_side, left_amount > 0 or left > 0, right_amount > 0 or right > 0)
397
+ event, tz, text = _common(time, timezone_offset, note)
398
+
399
+ def build(object_id: str, baby: Baby | None, timestamp: datetime) -> Pump:
400
+ # A pump never carries a baby, whatever the caller passes.
401
+ volumes: dict[str, Any] = {"amount": _measure(Volume(total, unit))}
402
+ if per_side:
403
+ volumes["left_amount"] = _measure(Volume(left_amount, unit))
404
+ volumes["right_amount"] = _measure(Volume(right_amount, unit))
405
+ return Pump(
406
+ **_base(object_id, None, timestamp, event, tz, text),
407
+ **volumes,
408
+ left_duration=left,
409
+ right_duration=right,
410
+ finish_side=side,
411
+ # Always 0 on every capture; its meaning isn't known.
412
+ sides=0,
413
+ )
414
+
415
+ return Draft(Pump, build)