Skip to content

Commit 688eb31

Browse files
committed
Merge remote-tracking branch 'origin/main'
2 parents 35e72a3 + b659f83 commit 688eb31

4 files changed

Lines changed: 176 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,29 @@
11
# CHANGELOG
22

33

4+
## v0.7.0 (2025-08-10)
5+
6+
### Features
7+
8+
- Enable fetching activity and logging diapers for babies
9+
([`91ba36d`](https://github.com/Lash-L/python-snoo/commit/91ba36d3a56e01cdb56a1ba7b25015d9268b2ef2))
10+
11+
feat: enable fetching activity & logging diapers for babies
12+
13+
- Enable fetching activity & logging diapers for babies
14+
([`31b86da`](https://github.com/Lash-L/python-snoo/commit/31b86da748ffeafb1e9ee5d365d5c8ecd8daae8d))
15+
16+
17+
## v0.6.7 (2025-06-30)
18+
19+
420
## v0.6.6 (2025-05-13)
521

22+
### Bug Fixes
23+
24+
- Improve reauth
25+
([`434a9d4`](https://github.com/Lash-L/python-snoo/commit/434a9d401b36f9d4b288623f0cb879646ae78ee4))
26+
627

728
## v0.6.5 (2025-03-27)
829

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "python-snoo"
3-
version = "0.6.6"
3+
version = "0.7.0"
44
description = "A package to control Snoos."
55
authors = ["Lash-L <Lash-L@users.noreply.github.com>"]
66
license = "GPL-3.0-only"

python_snoo/baby.py

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from python_snoo.containers import BabyData
1+
from datetime import datetime
2+
3+
from python_snoo.containers import Activity, BabyData, BreastfeedingActivity, DiaperActivity, DiaperTypes
24
from python_snoo.exceptions import SnooBabyError
35
from python_snoo.snoo import Snoo
46

@@ -8,6 +10,7 @@ def __init__(self, baby_id: str, snoo: Snoo):
810
self.baby_id = baby_id
911
self.snoo = snoo
1012
self.baby_url = f"https://api-us-east-1-prod.happiestbaby.com/us/me/v10/babies/{self.baby_id}"
13+
self.activity_base_url = "https://api-us-east-1-prod.happiestbaby.com/cs/me/v11"
1114

1215
@property
1316
def session(self):
@@ -21,3 +24,93 @@ async def get_status(self) -> BabyData:
2124
except Exception as ex:
2225
raise SnooBabyError from ex
2326
return BabyData.from_dict(resp)
27+
28+
async def get_activity_data(self, from_date: datetime, to_date: datetime) -> list[Activity]:
29+
"""Get activity data for this baby including feeding and diaper changes
30+
31+
Args:
32+
from_date: Start date for activity range
33+
to_date: End date for activity range
34+
35+
Returns:
36+
List of typed Activity objects (DiaperActivity or BreastfeedingActivity)
37+
"""
38+
hdrs = self.snoo.generate_snoo_auth_headers(self.snoo.tokens.aws_id)
39+
40+
url = f"{self.activity_base_url}/babies/{self.baby_id}/journals/grouped-tracking"
41+
42+
params = {
43+
"group": "activity",
44+
"fromDateTime": from_date.astimezone().isoformat(timespec="milliseconds"),
45+
"toDateTime": to_date.astimezone().isoformat(timespec="milliseconds"),
46+
}
47+
48+
try:
49+
r = await self.session.get(url, headers=hdrs, params=params)
50+
resp = await r.json()
51+
if r.status < 200 or r.status >= 300:
52+
raise SnooBabyError(f"Failed to get activity data: {r.status}: {resp}. Payload: {params}")
53+
54+
activities: list[Activity] = []
55+
if isinstance(resp, list):
56+
for activity in resp:
57+
activity_type = activity.get("type", "").lower()
58+
59+
if activity_type == "diaper":
60+
activities.append(DiaperActivity.from_dict(activity))
61+
elif activity_type == "breastfeeding":
62+
activities.append(BreastfeedingActivity.from_dict(activity))
63+
else:
64+
# Other activity types exist but aren't supported yet
65+
raise SnooBabyError(f"Unknown activity type: {activity_type}")
66+
else:
67+
raise SnooBabyError(f"Unexpected response format: {type(resp)}")
68+
69+
return activities
70+
71+
except Exception as ex:
72+
raise SnooBabyError from ex
73+
74+
async def log_diaper_change(
75+
self,
76+
diaper_types: list[DiaperTypes],
77+
note: str | None = None,
78+
start_time: datetime | None = None,
79+
) -> DiaperActivity:
80+
"""Log a diaper change for this baby
81+
82+
Args:
83+
diaper_types (list): List of diaper types. e.g. ['pee'], ['poo'], or ['pee', 'poo']
84+
note (str, optional): Optional note about the diaper change
85+
start_time (datetime, optional): Diaper change timestamp, doesn't allow length.
86+
Defaults to current local time if not provided.
87+
"""
88+
89+
if not start_time:
90+
start_time = datetime.now()
91+
92+
# Always include the timezone indicator in the ISO string - seems to be required by the API
93+
if start_time.tzinfo is None:
94+
start_time = start_time.astimezone()
95+
96+
hdrs = self.snoo.generate_snoo_auth_headers(self.snoo.tokens.aws_id)
97+
url = f"{self.activity_base_url}/journals"
98+
99+
payload = {
100+
"babyId": self.baby_id,
101+
"data": {"types": [dt.value for dt in diaper_types]},
102+
"type": "diaper",
103+
"startTime": start_time.isoformat(timespec="milliseconds"),
104+
}
105+
106+
if note:
107+
payload["note"] = note
108+
109+
try:
110+
r = await self.session.post(url, headers=hdrs, json=payload)
111+
resp = await r.json()
112+
if r.status < 200 or r.status >= 300:
113+
raise SnooBabyError(f"Failed to log diaper change: {r.status}: {resp}. Payload: {payload}")
114+
return DiaperActivity.from_dict(resp)
115+
except Exception as ex:
116+
raise SnooBabyError from ex

python_snoo/containers.py

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import dataclasses
22
import datetime
33
from enum import StrEnum
4-
from typing import Any
4+
from typing import Any, Union
55

66
from mashumaro.mixins.json import DataClassJSONMixin
77

@@ -47,6 +47,13 @@ class SnooEvents(StrEnum):
4747
RESTART = "restart"
4848

4949

50+
class DiaperTypes(StrEnum):
51+
"""Diaper change types, matching what the Happiest Baby app uses"""
52+
53+
WET = "pee"
54+
DIRTY = "poo"
55+
56+
5057
@dataclasses.dataclass
5158
class AuthorizationInfo:
5259
snoo: str
@@ -148,8 +155,58 @@ class BabyData(DataClassJSONMixin):
148155
disabledLimiter: bool
149156
expectedBirthDate: str
150157
pictures: list
151-
preemie: Any # Not sure what datatype this is yet
152158
settings: BabySettings
153-
sex: Any # Not sure what datatype this is yet
159+
sex: str
160+
preemie: Any | None = None # Not sure what datatype this is yet & may not be supplied - boolean?
154161
startedUsingSnooAt: str | None = None
155162
updatedAt: str | None = None
163+
164+
165+
@dataclasses.dataclass
166+
class DiaperData(DataClassJSONMixin):
167+
"""Data for diaper change activities"""
168+
169+
types: list[DiaperTypes]
170+
171+
def __post_init__(self):
172+
if not self.types:
173+
raise ValueError("DiaperData.types cannot be empty or None")
174+
175+
self.types = [DiaperTypes(dt) for dt in self.types]
176+
177+
178+
@dataclasses.dataclass
179+
class BreastfeedingData(DataClassJSONMixin):
180+
lastUsedBreast: str
181+
totalDuration: int
182+
left: dict | None = None
183+
right: dict | None = None
184+
185+
186+
@dataclasses.dataclass
187+
class DiaperActivity(DataClassJSONMixin):
188+
id: str
189+
type: str
190+
startTime: str
191+
babyId: str
192+
userId: str
193+
data: DiaperData
194+
createdAt: str
195+
updatedAt: str
196+
note: str | None = None
197+
198+
199+
@dataclasses.dataclass
200+
class BreastfeedingActivity(DataClassJSONMixin):
201+
id: str
202+
type: str
203+
startTime: str
204+
endTime: str
205+
babyId: str
206+
userId: str
207+
data: BreastfeedingData
208+
createdAt: str
209+
updatedAt: str
210+
211+
212+
Activity = Union[DiaperActivity, BreastfeedingActivity]

0 commit comments

Comments
 (0)