pybabytracker 0.1.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.
Files changed (41) hide show
  1. pybabytracker-0.1.0/.github/workflows/ci.yml +42 -0
  2. pybabytracker-0.1.0/.github/workflows/release.yml +59 -0
  3. pybabytracker-0.1.0/.gitignore +10 -0
  4. pybabytracker-0.1.0/LICENSE +21 -0
  5. pybabytracker-0.1.0/PKG-INFO +242 -0
  6. pybabytracker-0.1.0/README.md +217 -0
  7. pybabytracker-0.1.0/docs/icon.png +0 -0
  8. pybabytracker-0.1.0/pyproject.toml +63 -0
  9. pybabytracker-0.1.0/scripts/check_version.py +68 -0
  10. pybabytracker-0.1.0/src/pybabytracker/__init__.py +134 -0
  11. pybabytracker-0.1.0/src/pybabytracker/__main__.py +5 -0
  12. pybabytracker-0.1.0/src/pybabytracker/_version.py +1 -0
  13. pybabytracker-0.1.0/src/pybabytracker/builders.py +415 -0
  14. pybabytracker-0.1.0/src/pybabytracker/cli.py +366 -0
  15. pybabytracker-0.1.0/src/pybabytracker/client.py +287 -0
  16. pybabytracker-0.1.0/src/pybabytracker/engine.py +939 -0
  17. pybabytracker-0.1.0/src/pybabytracker/exceptions.py +63 -0
  18. pybabytracker-0.1.0/src/pybabytracker/models.py +599 -0
  19. pybabytracker-0.1.0/src/pybabytracker/py.typed +0 -0
  20. pybabytracker-0.1.0/src/pybabytracker/times.py +46 -0
  21. pybabytracker-0.1.0/tests/conftest.py +41 -0
  22. pybabytracker-0.1.0/tests/factories.py +146 -0
  23. pybabytracker-0.1.0/tests/fake_server.py +236 -0
  24. pybabytracker-0.1.0/tests/fixtures/captured_payloads.json +343 -0
  25. pybabytracker-0.1.0/tests/test_captured_models.py +429 -0
  26. pybabytracker-0.1.0/tests/test_check_version.py +159 -0
  27. pybabytracker-0.1.0/tests/test_cli.py +536 -0
  28. pybabytracker-0.1.0/tests/test_client.py +327 -0
  29. pybabytracker-0.1.0/tests/test_deleted_ids.py +141 -0
  30. pybabytracker-0.1.0/tests/test_engine.py +598 -0
  31. pybabytracker-0.1.0/tests/test_engine_tiebreak.py +425 -0
  32. pybabytracker-0.1.0/tests/test_models.py +199 -0
  33. pybabytracker-0.1.0/tests/test_own_writes.py +359 -0
  34. pybabytracker-0.1.0/tests/test_paging.py +339 -0
  35. pybabytracker-0.1.0/tests/test_pump_account_level.py +511 -0
  36. pybabytracker-0.1.0/tests/test_pump_timed_without_amount.py +207 -0
  37. pybabytracker-0.1.0/tests/test_sync_integration.py +155 -0
  38. pybabytracker-0.1.0/tests/test_timers.py +887 -0
  39. pybabytracker-0.1.0/tests/test_times.py +52 -0
  40. pybabytracker-0.1.0/tests/test_write_types.py +920 -0
  41. pybabytracker-0.1.0/tests/test_writes.py +1047 -0
@@ -0,0 +1,42 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_call:
8
+ inputs:
9
+ no-cache:
10
+ description: Skip the pip cache (used by release runs)
11
+ type: boolean
12
+ default: false
13
+
14
+ permissions:
15
+ contents: read
16
+
17
+ jobs:
18
+ test:
19
+ runs-on: ubuntu-latest
20
+ strategy:
21
+ fail-fast: false
22
+ matrix:
23
+ python-version: ["3.12", "3.13", "3.14"]
24
+ steps:
25
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
26
+ with:
27
+ persist-credentials: false
28
+ - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
29
+ with:
30
+ python-version: ${{ matrix.python-version }}
31
+ cache: ${{ inputs.no-cache != true && 'pip' || '' }}
32
+ cache-dependency-path: pyproject.toml
33
+ - name: Install
34
+ run: pip install -e ".[dev]"
35
+ - name: pytest
36
+ run: pytest
37
+ - name: mypy
38
+ run: mypy
39
+ - name: ruff check
40
+ run: ruff check .
41
+ - name: ruff format
42
+ run: ruff format --check .
@@ -0,0 +1,59 @@
1
+ name: Release
2
+
3
+ # Publishes to PyPI when a version tag is pushed. Nothing else triggers it.
4
+ on:
5
+ push:
6
+ tags: ["v[0-9]*"]
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ test:
13
+ uses: ./.github/workflows/ci.yml
14
+ with:
15
+ no-cache: true
16
+
17
+ build:
18
+ needs: test
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
22
+ with:
23
+ persist-credentials: false
24
+ - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
25
+ with:
26
+ python-version: "3.12"
27
+ - name: Check the tag matches the package version
28
+ env:
29
+ TAG: ${{ github.ref_name }}
30
+ run: python scripts/check_version.py "$TAG"
31
+ - name: Install build tools
32
+ run: pip install uv==0.13.6 twine==7.0.0
33
+ - name: Build sdist and wheel
34
+ run: uv build
35
+ - name: Check the artifacts
36
+ run: twine check --strict dist/*
37
+ - name: Install the wheel in a clean venv
38
+ run: |
39
+ uv venv "$RUNNER_TEMP/wheel-check"
40
+ uv pip install --python "$RUNNER_TEMP/wheel-check/bin/python" dist/*.whl
41
+ "$RUNNER_TEMP/wheel-check/bin/python" -c "import pybabytracker; print(pybabytracker.__version__)"
42
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
43
+ with:
44
+ name: dist
45
+ path: dist/
46
+ if-no-files-found: error
47
+
48
+ publish:
49
+ needs: build
50
+ runs-on: ubuntu-latest
51
+ environment: pypi
52
+ permissions:
53
+ id-token: write # trusted publishing: PyPI exchanges this OIDC token, so no API token is stored
54
+ steps:
55
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
56
+ with:
57
+ name: dist
58
+ path: dist/
59
+ - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
@@ -0,0 +1,10 @@
1
+ .env
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ .pytest_cache/
6
+ dist/
7
+ *.egg-info/
8
+ .pybabytracker-device.json
9
+ .pybabytracker-test-device.json
10
+ .pybabytracker-test-state.json
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Will Adler
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,242 @@
1
+ Metadata-Version: 2.5
2
+ Name: pybabytracker
3
+ Version: 0.1.0
4
+ Summary: Async Python client for the Nighp Baby Tracker sync service
5
+ Project-URL: Source, https://github.com/wtadler/pybabytracker
6
+ Project-URL: Issues, https://github.com/wtadler/pybabytracker/issues
7
+ Author: Will Adler
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: aiohttp>=3.10
19
+ Provides-Extra: dev
20
+ Requires-Dist: mypy>=1.11; extra == 'dev'
21
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
22
+ Requires-Dist: pytest>=8; extra == 'dev'
23
+ Requires-Dist: ruff>=0.6; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ <p align="center">
27
+ <img src="https://github.com/wtadler/pybabytracker/raw/main/docs/icon.png" alt="Baby Tracker icon" width="128">
28
+ </p>
29
+
30
+ # pybabytracker
31
+
32
+ Async Python client for the sync service of
33
+ [Baby Tracker](https://apps.apple.com/us/app/baby-tracker-newborn-log/id779656557), the app by
34
+ [Nighp](https://nighp.com), built for a Home Assistant integration. It is unofficial and
35
+ identifies itself honestly (`pybabytracker/<version>`).
36
+
37
+ ## Use
38
+
39
+ ```python
40
+ import aiohttp
41
+ from pybabytracker import DIAPER_WET, BabyTrackerClient, SyncEngine
42
+
43
+ async with aiohttp.ClientSession() as session:
44
+ client = BabyTrackerClient(
45
+ "parent@example.com", "password", "STABLE-DEVICE-UUID", "Home Assistant", session=session
46
+ )
47
+ engine = SyncEngine(device_uuid="STABLE-DEVICE-UUID") # or SyncEngine.from_dict(saved_state)
48
+ await engine.sync(client) # fetches only devices that are ahead of what was seen
49
+ diaper = await engine.add_diaper(client, baby_id, DIAPER_WET) # posts as this device
50
+ await engine.delete_record(client, diaper.object_id) # only records this device created
51
+ saved_state = engine.to_dict() # JSON-safe, for the caller to persist
52
+ ```
53
+
54
+ - Pass your own `aiohttp.ClientSession` (the client never closes it), and make it dedicated to
55
+ this client: the service authenticates by cookie, so two accounts must not share a session (in
56
+ Home Assistant, use `async_create_clientsession`). Without one, the client makes its own and
57
+ closes it in `close()`.
58
+ - `AuthenticationError` means the credentials were rejected. `BabyTrackerConnectionError` means
59
+ the service was unreachable or answered with a server error. A 401 mid-session triggers one
60
+ sign-in and a retry before it becomes an `AuthenticationError`.
61
+ - Records are typed (`Diaper`, `Sleep`, `Nursing`, and so on). A record type the library
62
+ doesn't know stays raw as an `UnknownRecord` with a logged warning. Decoding then encoding any
63
+ payload returns the same JSON, including keys the library doesn't model.
64
+
65
+ ## Writes
66
+
67
+ The engine posts as its own device (`device_uuid`, which must match the client's):
68
+
69
+ - A write needs a completed sync first (`NotSyncedError` otherwise), because the payload embeds
70
+ the baby record as known from sync (`UnknownBabyError` for a baby it hasn't seen).
71
+ - Numbers are at-most-once. The next number is one more than the highest of this device's
72
+ `LastSyncID` on the server, its own log already applied, and its last confirmed post. Before
73
+ choosing, the engine applies any of its own log it hasn't seen (another process using the same
74
+ device ID).
75
+ - When a post fails, the engine reads its own log at that number. If the same transaction is
76
+ there, the write succeeded. Otherwise the error is raised and the post stays pending; the next
77
+ write settles it first. After a definite HTTP error, a number nothing holds is reused. After
78
+ no answer at all (`NoResponseError`: a timeout or dropped connection), the post may still
79
+ land, so the number is reused only once `unknown_outcome_grace` seconds (default 90) have
80
+ passed with nothing under it; until then a write raises `WriteOutcomeUnknownError` without
81
+ posting. A 401 signs in again and resends the same number. Nothing is resent blindly.
82
+ - To retry an add safely, pass the same `object_id` again: if the earlier attempt landed after
83
+ all, its record is returned instead of a second copy being posted.
84
+ - If this device's own log is shorter than its `LastSyncID`, a write is refused
85
+ (`ProtocolError`) rather than skipping the missing entries.
86
+ - A confirmed write is applied locally at once, so the next sync doesn't fetch it back.
87
+ - Writes and syncs share a lock, so concurrent writes get consecutive numbers.
88
+ - `delete_record` only deletes records this device added (`NotOwnRecordError`), and raises
89
+ `RecordNotFoundError` if one is already deleted. Like the app, the delete sends the record as
90
+ last saved with only `newFlage` set to `"false"`; its timestamp is not bumped.
91
+ - Save `to_dict()` after every write, including one that raised: a pending post lives only in
92
+ that state. Use one device ID per running process; two processes writing as one device can
93
+ collide on a number while `LastSyncID` lags.
94
+
95
+ ### Record types you can add
96
+
97
+ Every add shares the rules above (numbering, pending posts, `object_id` retries, local apply),
98
+ and bad input raises `ValueError` before any request. Payloads match the iPad capture key for
99
+ key.
100
+
101
+ | Method | Writes | Notes |
102
+ |---|---|---|
103
+ | `add_diaper(client, baby_id, status, ...)` | `Diaper` | `DIAPER_WET`, `DIAPER_DIRTY`, `DIAPER_MIXED` |
104
+ | `add_formula(client, baby_id, volume=, ...)` | `Formula` | volume more than zero |
105
+ | `add_pumped(client, baby_id, volume=, ...)` | `Pumped` | a bottle of pumped milk |
106
+ | `add_nursing(client, baby_id, left_minutes=, right_minutes=, ...)` | `Nursing` | a finished session |
107
+ | `add_sleep(client, baby_id, start=, end=, ...)` | `Sleep` | a finished sleep |
108
+ | `add_pump(client, amount=, ...)` or `add_pump(client, left_volume=, right_volume=, ...)` | `Pump` | a pumping session; belongs to the account, so no `baby_id` |
109
+
110
+ - Volumes are `Volume(value, "oz")` or `Volume(value, "ml")`, written as a `VolumeMeasure` with
111
+ `englishMeasure` `"true"` (ounces) or `"false"` (milliliters), rounded to 2 decimals (an integer
112
+ when whole). The number is written as given, with no conversion, as the app does.
113
+ - `time` is when the activity started (or, for a bottle or diaper, when it happened), written in
114
+ UTC. `timezone_offset` (seconds east of UTC) defaults to the offset of the time given. The app itself writes the zone's *standard* offset
115
+ all year, ignoring daylight saving, so to match it pass
116
+ `timezone_offset=standard_offset_seconds(tz, time)`: New York is -18000 in July and January.
117
+ - Nursing: `time` is the session's start, as in the capture. Give `start_time`, or `end_time`
118
+ (start is end minus the total minutes), or neither (the session just ended). `finish_side` is
119
+ `SIDE_LEFT` or `SIDE_RIGHT`, defaulting to the last side used (right when both were). For a pump that default is a guess
120
+ (the one captured pump with both sides finished left), so pass `finish_side` yourself.
121
+ - Sleep: `time` is `start`. `duration` is whole minutes: the length rounded half up (the capture
122
+ stored about 30 seconds as 1), and at least 1, because a duration of 0 means a sleep in
123
+ progress. `end` must be after `start`, and a sleep can't be longer than 24 hours.
124
+ - Nursing and pump minutes are whole numbers, at most 240 per side (a guard against seconds
125
+ passed as minutes).
126
+ - Pump: a pump belongs to the account, not a baby, so it takes no `baby_id` and its payload has
127
+ no `baby` key, like the app's. Give the total as `amount`, or the side volumes (not both):
128
+ with only `amount` the payload has no per-side amounts, and with sides `amount` is their sum,
129
+ a side left out is written as 0 in the other's unit, and both sides must use one unit.
130
+ With no volume at all, minutes are required and the payload carries the durations and an
131
+ `amount` of 0 in `default_unit` (default `oz`), as the app writes a timed pump with no volume.
132
+ `sides` is written as 0, as captured. Babies are never derived from pumps, and a pump that
133
+ carries a `baby` still decodes (kept raw, not read). `engine.latest_pump()` is the newest pump
134
+ on the account; `latest_records(baby_id)` never includes pumps.
135
+
136
+ ### Sleep timers
137
+
138
+ The app syncs a running sleep: an add with `duration` 0 at the start, then an update (opcode 1)
139
+ to the same record at the stop. Nursing timers stay on the phone until they stop, so a timed
140
+ nursing session is written once, finished, with `add_nursing`.
141
+
142
+ | Method | Writes | Notes |
143
+ |---|---|---|
144
+ | `start_sleep(client, baby_id, start=None, ...)` | `Sleep` (add) | `duration` 0; `start` defaults to now, no more than a minute ahead or 24 hours back |
145
+ | `end_sleep(client, object_id, end=None)` | `Sleep` (update) | ends a running sleep, whichever device started it |
146
+
147
+ - `end_sleep` sends the record as last saved, changing only `duration` (rounded like
148
+ `add_sleep`), `newFlage` (`"false"`), and `timestamp` (the write time, or one second past the
149
+ stored one when that's ahead of this clock, so the end wins the merge). Key order and unknown
150
+ keys are kept.
151
+ - A sleep longer than 24 hours at the stop is **clamped to 1,440 minutes** with a warning, not
152
+ refused, so a forgotten timer can always be stopped. An `end` more than a minute in the future
153
+ is clamped to now, and an end at or up to a minute before the start (a clock running a little
154
+ fast) is written as 1 minute.
155
+ - Refused before any request: a record never seen or deleted (`RecordNotFoundError`), one that
156
+ isn't a sleep (`WrongRecordTypeError`), or a sleep another device finished
157
+ (`TimerNotRunningError`). A sleep this device already ended (its end is in this device's log,
158
+ even if a phone edited it since) is returned as is, with nothing posted, so retries are safe.
159
+ - The end carries the whole record as this device last saw it, so a phone edit that hasn't synced
160
+ yet (a note, say) is overwritten by it, as the app's own edits overwrite each other.
161
+ - A phone delete racing an end: the app's delete keeps the record's old timestamp, so an end
162
+ posted after it (with a newer timestamp) outlives it, and the sleep stays here as ended. How the
163
+ app shows that case is still to be checked on a test device.
164
+ - An end is an update, not an add: it doesn't make another device's sleep this device's own, so
165
+ `delete_record` still refuses it. Undo of a sleep this device started works running or ended.
166
+
167
+ The service lists a device only after its first post; on the test account, the dev device
168
+ appeared with `LastSyncID` 1 right after its first add, with no confirmation step.
169
+
170
+ ## Merge rules
171
+
172
+ Records are keyed by `objectID`. Each stored record carries a version made of the record's
173
+ `timestamp`, the device, and the sync number. Within one device's log, later numbers always win.
174
+ Across devices the result doesn't depend on which device is fetched first:
175
+
176
+ - An add or update replaces a stored record if its (timestamp, device, number) is newer.
177
+ - A delete removes the record unless the record's timestamp is newer than the delete's, and it
178
+ leaves a tombstone, so a delete that arrives before the add it targets still wins.
179
+ - An add or update from another device brings a deleted record back only with a strictly newer
180
+ timestamp than the tombstone. Within one device, a later add always wins.
181
+
182
+ Tombstones are saved by `to_dict()`. State saved by an older version (1) loads with none.
183
+ A payload that can't be decoded at all is skipped with a warning, and the sync moves past it. A
184
+ fresh `SyncEngine` replays every log, so it recovers skipped payloads once a fix ships.
185
+
186
+ ## Command line
187
+
188
+ ```sh
189
+ export BT_EMAIL=... BT_PASSWORD=... # or put them in .env
190
+ python -m pybabytracker # latest record of each type, per baby
191
+ python -m pybabytracker --probe-history
192
+
193
+ # Writes go to a throwaway test account only (BT_TEST_EMAIL, BT_TEST_PASSWORD):
194
+ python -m pybabytracker --write-test-diaper [--status wet|dirty|mixed]
195
+ python -m pybabytracker --delete-test-diaper OBJECT_ID
196
+ ```
197
+
198
+ The tool signs in as a separate device named "Home Assistant (dev)". Its ID is made once and
199
+ kept in `.pybabytracker-device.json` (gitignored). `--probe-history` reads every device's log
200
+ from 0 and reports how many transactions come back versus its `LastSyncID`, the lowest and
201
+ highest numbers returned, and a verdict (full, paged, paged newest first, starts at K with older
202
+ numbers trimmed, duplicates, or gaps), then whether the probe device now appears in the device list. It only reads.
203
+
204
+ `--write-test-diaper` adds one diaper for the test account's first baby and reports whether the
205
+ dev device is in the device list before and after, with its `LastSyncID`, and whether the next
206
+ sync fetched its own log back. `--delete-test-diaper` deletes one the dev device wrote. Both
207
+ read only the `BT_TEST_*` credentials, and keep their device ID and engine state in
208
+ `.pybabytracker-test-device.json` and `.pybabytracker-test-state.json` (both gitignored).
209
+
210
+ ## Develop
211
+
212
+ ```sh
213
+ uv venv --python 3.12 && uv pip install -e ".[dev]"
214
+ pytest && mypy && ruff check .
215
+ ```
216
+
217
+ ## Release
218
+
219
+ Releases publish to PyPI from a `v<version>` tag such as `v0.2.0` through trusted publishing: there is no API token
220
+ anywhere. `.github/workflows/release.yml` runs on that tag and needs the tests to pass, then
221
+ builds the sdist and wheel, checks them with `twine check --strict`, installs the wheel in a clean
222
+ venv, and publishes from the GitHub environment named `pypi`.
223
+
224
+ To cut a release:
225
+
226
+ 1. Set `__version__` in `src/pybabytracker/_version.py` (for example `0.2.0`, or `0.2.0rc1` for a
227
+ pre-release) and merge it to `main`.
228
+ 2. Tag that commit with the same version and push the tag: `git tag v0.2.0 && git push origin v0.2.0`.
229
+ The workflow fails before building if the tag and `_version.py` disagree
230
+ (`python scripts/check_version.py v0.2.0` runs the same check locally).
231
+
232
+ One-time setup, done on PyPI and in GitHub before the first tag:
233
+
234
+ 1. On PyPI, add a pending publisher at <https://pypi.org/manage/account/publishing/> for
235
+ the project name `pybabytracker`, with owner `wtadler`, repository `pybabytracker`, workflow
236
+ `release.yml`, and environment `pypi`.
237
+ 2. In the repository settings, create an environment named `pypi`, and under deployment branches
238
+ and tags choose Selected branches and tags with the tag rule `v*`, so only a version tag can
239
+ reach the publish step (a branch could otherwise run the workflow with the publish
240
+ permission). Adding required reviewers there makes each publish wait for an approval.
241
+
242
+ The first successful publish turns the pending publisher into a normal one.
@@ -0,0 +1,217 @@
1
+ <p align="center">
2
+ <img src="https://github.com/wtadler/pybabytracker/raw/main/docs/icon.png" alt="Baby Tracker icon" width="128">
3
+ </p>
4
+
5
+ # pybabytracker
6
+
7
+ Async Python client for the sync service of
8
+ [Baby Tracker](https://apps.apple.com/us/app/baby-tracker-newborn-log/id779656557), the app by
9
+ [Nighp](https://nighp.com), built for a Home Assistant integration. It is unofficial and
10
+ identifies itself honestly (`pybabytracker/<version>`).
11
+
12
+ ## Use
13
+
14
+ ```python
15
+ import aiohttp
16
+ from pybabytracker import DIAPER_WET, BabyTrackerClient, SyncEngine
17
+
18
+ async with aiohttp.ClientSession() as session:
19
+ client = BabyTrackerClient(
20
+ "parent@example.com", "password", "STABLE-DEVICE-UUID", "Home Assistant", session=session
21
+ )
22
+ engine = SyncEngine(device_uuid="STABLE-DEVICE-UUID") # or SyncEngine.from_dict(saved_state)
23
+ await engine.sync(client) # fetches only devices that are ahead of what was seen
24
+ diaper = await engine.add_diaper(client, baby_id, DIAPER_WET) # posts as this device
25
+ await engine.delete_record(client, diaper.object_id) # only records this device created
26
+ saved_state = engine.to_dict() # JSON-safe, for the caller to persist
27
+ ```
28
+
29
+ - Pass your own `aiohttp.ClientSession` (the client never closes it), and make it dedicated to
30
+ this client: the service authenticates by cookie, so two accounts must not share a session (in
31
+ Home Assistant, use `async_create_clientsession`). Without one, the client makes its own and
32
+ closes it in `close()`.
33
+ - `AuthenticationError` means the credentials were rejected. `BabyTrackerConnectionError` means
34
+ the service was unreachable or answered with a server error. A 401 mid-session triggers one
35
+ sign-in and a retry before it becomes an `AuthenticationError`.
36
+ - Records are typed (`Diaper`, `Sleep`, `Nursing`, and so on). A record type the library
37
+ doesn't know stays raw as an `UnknownRecord` with a logged warning. Decoding then encoding any
38
+ payload returns the same JSON, including keys the library doesn't model.
39
+
40
+ ## Writes
41
+
42
+ The engine posts as its own device (`device_uuid`, which must match the client's):
43
+
44
+ - A write needs a completed sync first (`NotSyncedError` otherwise), because the payload embeds
45
+ the baby record as known from sync (`UnknownBabyError` for a baby it hasn't seen).
46
+ - Numbers are at-most-once. The next number is one more than the highest of this device's
47
+ `LastSyncID` on the server, its own log already applied, and its last confirmed post. Before
48
+ choosing, the engine applies any of its own log it hasn't seen (another process using the same
49
+ device ID).
50
+ - When a post fails, the engine reads its own log at that number. If the same transaction is
51
+ there, the write succeeded. Otherwise the error is raised and the post stays pending; the next
52
+ write settles it first. After a definite HTTP error, a number nothing holds is reused. After
53
+ no answer at all (`NoResponseError`: a timeout or dropped connection), the post may still
54
+ land, so the number is reused only once `unknown_outcome_grace` seconds (default 90) have
55
+ passed with nothing under it; until then a write raises `WriteOutcomeUnknownError` without
56
+ posting. A 401 signs in again and resends the same number. Nothing is resent blindly.
57
+ - To retry an add safely, pass the same `object_id` again: if the earlier attempt landed after
58
+ all, its record is returned instead of a second copy being posted.
59
+ - If this device's own log is shorter than its `LastSyncID`, a write is refused
60
+ (`ProtocolError`) rather than skipping the missing entries.
61
+ - A confirmed write is applied locally at once, so the next sync doesn't fetch it back.
62
+ - Writes and syncs share a lock, so concurrent writes get consecutive numbers.
63
+ - `delete_record` only deletes records this device added (`NotOwnRecordError`), and raises
64
+ `RecordNotFoundError` if one is already deleted. Like the app, the delete sends the record as
65
+ last saved with only `newFlage` set to `"false"`; its timestamp is not bumped.
66
+ - Save `to_dict()` after every write, including one that raised: a pending post lives only in
67
+ that state. Use one device ID per running process; two processes writing as one device can
68
+ collide on a number while `LastSyncID` lags.
69
+
70
+ ### Record types you can add
71
+
72
+ Every add shares the rules above (numbering, pending posts, `object_id` retries, local apply),
73
+ and bad input raises `ValueError` before any request. Payloads match the iPad capture key for
74
+ key.
75
+
76
+ | Method | Writes | Notes |
77
+ |---|---|---|
78
+ | `add_diaper(client, baby_id, status, ...)` | `Diaper` | `DIAPER_WET`, `DIAPER_DIRTY`, `DIAPER_MIXED` |
79
+ | `add_formula(client, baby_id, volume=, ...)` | `Formula` | volume more than zero |
80
+ | `add_pumped(client, baby_id, volume=, ...)` | `Pumped` | a bottle of pumped milk |
81
+ | `add_nursing(client, baby_id, left_minutes=, right_minutes=, ...)` | `Nursing` | a finished session |
82
+ | `add_sleep(client, baby_id, start=, end=, ...)` | `Sleep` | a finished sleep |
83
+ | `add_pump(client, amount=, ...)` or `add_pump(client, left_volume=, right_volume=, ...)` | `Pump` | a pumping session; belongs to the account, so no `baby_id` |
84
+
85
+ - Volumes are `Volume(value, "oz")` or `Volume(value, "ml")`, written as a `VolumeMeasure` with
86
+ `englishMeasure` `"true"` (ounces) or `"false"` (milliliters), rounded to 2 decimals (an integer
87
+ when whole). The number is written as given, with no conversion, as the app does.
88
+ - `time` is when the activity started (or, for a bottle or diaper, when it happened), written in
89
+ UTC. `timezone_offset` (seconds east of UTC) defaults to the offset of the time given. The app itself writes the zone's *standard* offset
90
+ all year, ignoring daylight saving, so to match it pass
91
+ `timezone_offset=standard_offset_seconds(tz, time)`: New York is -18000 in July and January.
92
+ - Nursing: `time` is the session's start, as in the capture. Give `start_time`, or `end_time`
93
+ (start is end minus the total minutes), or neither (the session just ended). `finish_side` is
94
+ `SIDE_LEFT` or `SIDE_RIGHT`, defaulting to the last side used (right when both were). For a pump that default is a guess
95
+ (the one captured pump with both sides finished left), so pass `finish_side` yourself.
96
+ - Sleep: `time` is `start`. `duration` is whole minutes: the length rounded half up (the capture
97
+ stored about 30 seconds as 1), and at least 1, because a duration of 0 means a sleep in
98
+ progress. `end` must be after `start`, and a sleep can't be longer than 24 hours.
99
+ - Nursing and pump minutes are whole numbers, at most 240 per side (a guard against seconds
100
+ passed as minutes).
101
+ - Pump: a pump belongs to the account, not a baby, so it takes no `baby_id` and its payload has
102
+ no `baby` key, like the app's. Give the total as `amount`, or the side volumes (not both):
103
+ with only `amount` the payload has no per-side amounts, and with sides `amount` is their sum,
104
+ a side left out is written as 0 in the other's unit, and both sides must use one unit.
105
+ With no volume at all, minutes are required and the payload carries the durations and an
106
+ `amount` of 0 in `default_unit` (default `oz`), as the app writes a timed pump with no volume.
107
+ `sides` is written as 0, as captured. Babies are never derived from pumps, and a pump that
108
+ carries a `baby` still decodes (kept raw, not read). `engine.latest_pump()` is the newest pump
109
+ on the account; `latest_records(baby_id)` never includes pumps.
110
+
111
+ ### Sleep timers
112
+
113
+ The app syncs a running sleep: an add with `duration` 0 at the start, then an update (opcode 1)
114
+ to the same record at the stop. Nursing timers stay on the phone until they stop, so a timed
115
+ nursing session is written once, finished, with `add_nursing`.
116
+
117
+ | Method | Writes | Notes |
118
+ |---|---|---|
119
+ | `start_sleep(client, baby_id, start=None, ...)` | `Sleep` (add) | `duration` 0; `start` defaults to now, no more than a minute ahead or 24 hours back |
120
+ | `end_sleep(client, object_id, end=None)` | `Sleep` (update) | ends a running sleep, whichever device started it |
121
+
122
+ - `end_sleep` sends the record as last saved, changing only `duration` (rounded like
123
+ `add_sleep`), `newFlage` (`"false"`), and `timestamp` (the write time, or one second past the
124
+ stored one when that's ahead of this clock, so the end wins the merge). Key order and unknown
125
+ keys are kept.
126
+ - A sleep longer than 24 hours at the stop is **clamped to 1,440 minutes** with a warning, not
127
+ refused, so a forgotten timer can always be stopped. An `end` more than a minute in the future
128
+ is clamped to now, and an end at or up to a minute before the start (a clock running a little
129
+ fast) is written as 1 minute.
130
+ - Refused before any request: a record never seen or deleted (`RecordNotFoundError`), one that
131
+ isn't a sleep (`WrongRecordTypeError`), or a sleep another device finished
132
+ (`TimerNotRunningError`). A sleep this device already ended (its end is in this device's log,
133
+ even if a phone edited it since) is returned as is, with nothing posted, so retries are safe.
134
+ - The end carries the whole record as this device last saw it, so a phone edit that hasn't synced
135
+ yet (a note, say) is overwritten by it, as the app's own edits overwrite each other.
136
+ - A phone delete racing an end: the app's delete keeps the record's old timestamp, so an end
137
+ posted after it (with a newer timestamp) outlives it, and the sleep stays here as ended. How the
138
+ app shows that case is still to be checked on a test device.
139
+ - An end is an update, not an add: it doesn't make another device's sleep this device's own, so
140
+ `delete_record` still refuses it. Undo of a sleep this device started works running or ended.
141
+
142
+ The service lists a device only after its first post; on the test account, the dev device
143
+ appeared with `LastSyncID` 1 right after its first add, with no confirmation step.
144
+
145
+ ## Merge rules
146
+
147
+ Records are keyed by `objectID`. Each stored record carries a version made of the record's
148
+ `timestamp`, the device, and the sync number. Within one device's log, later numbers always win.
149
+ Across devices the result doesn't depend on which device is fetched first:
150
+
151
+ - An add or update replaces a stored record if its (timestamp, device, number) is newer.
152
+ - A delete removes the record unless the record's timestamp is newer than the delete's, and it
153
+ leaves a tombstone, so a delete that arrives before the add it targets still wins.
154
+ - An add or update from another device brings a deleted record back only with a strictly newer
155
+ timestamp than the tombstone. Within one device, a later add always wins.
156
+
157
+ Tombstones are saved by `to_dict()`. State saved by an older version (1) loads with none.
158
+ A payload that can't be decoded at all is skipped with a warning, and the sync moves past it. A
159
+ fresh `SyncEngine` replays every log, so it recovers skipped payloads once a fix ships.
160
+
161
+ ## Command line
162
+
163
+ ```sh
164
+ export BT_EMAIL=... BT_PASSWORD=... # or put them in .env
165
+ python -m pybabytracker # latest record of each type, per baby
166
+ python -m pybabytracker --probe-history
167
+
168
+ # Writes go to a throwaway test account only (BT_TEST_EMAIL, BT_TEST_PASSWORD):
169
+ python -m pybabytracker --write-test-diaper [--status wet|dirty|mixed]
170
+ python -m pybabytracker --delete-test-diaper OBJECT_ID
171
+ ```
172
+
173
+ The tool signs in as a separate device named "Home Assistant (dev)". Its ID is made once and
174
+ kept in `.pybabytracker-device.json` (gitignored). `--probe-history` reads every device's log
175
+ from 0 and reports how many transactions come back versus its `LastSyncID`, the lowest and
176
+ highest numbers returned, and a verdict (full, paged, paged newest first, starts at K with older
177
+ numbers trimmed, duplicates, or gaps), then whether the probe device now appears in the device list. It only reads.
178
+
179
+ `--write-test-diaper` adds one diaper for the test account's first baby and reports whether the
180
+ dev device is in the device list before and after, with its `LastSyncID`, and whether the next
181
+ sync fetched its own log back. `--delete-test-diaper` deletes one the dev device wrote. Both
182
+ read only the `BT_TEST_*` credentials, and keep their device ID and engine state in
183
+ `.pybabytracker-test-device.json` and `.pybabytracker-test-state.json` (both gitignored).
184
+
185
+ ## Develop
186
+
187
+ ```sh
188
+ uv venv --python 3.12 && uv pip install -e ".[dev]"
189
+ pytest && mypy && ruff check .
190
+ ```
191
+
192
+ ## Release
193
+
194
+ Releases publish to PyPI from a `v<version>` tag such as `v0.2.0` through trusted publishing: there is no API token
195
+ anywhere. `.github/workflows/release.yml` runs on that tag and needs the tests to pass, then
196
+ builds the sdist and wheel, checks them with `twine check --strict`, installs the wheel in a clean
197
+ venv, and publishes from the GitHub environment named `pypi`.
198
+
199
+ To cut a release:
200
+
201
+ 1. Set `__version__` in `src/pybabytracker/_version.py` (for example `0.2.0`, or `0.2.0rc1` for a
202
+ pre-release) and merge it to `main`.
203
+ 2. Tag that commit with the same version and push the tag: `git tag v0.2.0 && git push origin v0.2.0`.
204
+ The workflow fails before building if the tag and `_version.py` disagree
205
+ (`python scripts/check_version.py v0.2.0` runs the same check locally).
206
+
207
+ One-time setup, done on PyPI and in GitHub before the first tag:
208
+
209
+ 1. On PyPI, add a pending publisher at <https://pypi.org/manage/account/publishing/> for
210
+ the project name `pybabytracker`, with owner `wtadler`, repository `pybabytracker`, workflow
211
+ `release.yml`, and environment `pypi`.
212
+ 2. In the repository settings, create an environment named `pypi`, and under deployment branches
213
+ and tags choose Selected branches and tags with the tag rule `v*`, so only a version tag can
214
+ reach the publish step (a branch could otherwise run the workflow with the publish
215
+ permission). Adding required reviewers there makes each publish wait for an approval.
216
+
217
+ The first successful publish turns the pending publisher into a normal one.
Binary file
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pybabytracker"
7
+ dynamic = ["version"]
8
+ description = "Async Python client for the Nighp Baby Tracker sync service"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.12"
13
+ authors = [{ name = "Will Adler" }]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Framework :: AsyncIO",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = ["aiohttp>=3.10"]
24
+
25
+ [project.urls]
26
+ Source = "https://github.com/wtadler/pybabytracker"
27
+ Issues = "https://github.com/wtadler/pybabytracker/issues"
28
+
29
+ [project.optional-dependencies]
30
+ dev = [
31
+ "pytest>=8",
32
+ "pytest-asyncio>=0.24",
33
+ "mypy>=1.11",
34
+ "ruff>=0.6",
35
+ ]
36
+
37
+ [project.scripts]
38
+ pybabytracker = "pybabytracker.cli:main"
39
+
40
+ [tool.hatch.version]
41
+ path = "src/pybabytracker/_version.py"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/pybabytracker"]
45
+
46
+ [tool.pytest.ini_options]
47
+ asyncio_mode = "auto"
48
+ asyncio_default_fixture_loop_scope = "function"
49
+ testpaths = ["tests"]
50
+ pythonpath = ["tests"]
51
+ filterwarnings = ["error"]
52
+
53
+ [tool.mypy]
54
+ strict = true
55
+ python_version = "3.12"
56
+ files = ["src", "tests"]
57
+
58
+ [tool.ruff]
59
+ line-length = 100
60
+ target-version = "py312"
61
+
62
+ [tool.ruff.lint]
63
+ select = ["E", "F", "I", "UP", "B", "SIM", "ASYNC"]