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.
- pybabytracker/__init__.py +134 -0
- pybabytracker/__main__.py +5 -0
- pybabytracker/_version.py +1 -0
- pybabytracker/builders.py +415 -0
- pybabytracker/cli.py +366 -0
- pybabytracker/client.py +287 -0
- pybabytracker/engine.py +939 -0
- pybabytracker/exceptions.py +63 -0
- pybabytracker/models.py +599 -0
- pybabytracker/py.typed +0 -0
- pybabytracker/times.py +46 -0
- pybabytracker-0.1.0.dist-info/METADATA +242 -0
- pybabytracker-0.1.0.dist-info/RECORD +16 -0
- pybabytracker-0.1.0.dist-info/WHEEL +4 -0
- pybabytracker-0.1.0.dist-info/entry_points.txt +2 -0
- pybabytracker-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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 @@
|
|
|
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)
|