"""Wire format for Baby Tracker sync records. A record is a JSON object that travels base64 encoded inside a transaction. Field names mirror the app's Java classes because the app serializes them with Gson reflection. Records stay plain dicts so that fields this module does not know about survive a download/modify/upload round trip. """ from __future__ import annotations import base64 import json import uuid from dataclasses import dataclass from datetime import datetime, timedelta, timezone from enum import IntEnum from typing import Any, Mapping, Optional TYPE_FIELD = "BCObjectType" # Types registered with the app's RuntimeTypeAdapterFactory (utility/GsonUtils). # The Android app rejects a record whose BCObjectType is not in this set. KNOWN_TYPES = frozenset( { "Activity", "Baby", "BabyActivity", "Bath", "Bottle", "ConflictRecord", "Diaper", "EditableSelection", "Feed", "Formula", "Growth", "Journal", "Joy", "Medication", "MedicationSelection", "Milestone", "MilestoneSelection", "Nursing", "NursingSession", "OtherActivity", "OtherActivityDescription", "OtherFeed", "OtherFeedSelection", "Picture", "Pump", "Pumped", "Sleep", "Temperature", "Vaccine", "VaccineSelection", } ) class OpCode(IntEnum): INSERT = 0 UPDATE = 1 DELETE = 2 RELIVE = 3 CONFLICT = 4 class DiaperStatus(IntEnum): WET = 0 POOPY = 1 MIXED = 2 DRY = 3 class DiaperAmount(IntEnum): NONE = 0 LIGHT = 1 NORMAL = 2 HEAVY = 3 OVERFLOW = 4 NOSET = 5 class NursingFinishSide(IntEnum): NOT_SET = 0 LEFT = 1 RIGHT = 2 class PumpFinishSide(IntEnum): NONE = 0 LEFT = 1 RIGHT = 2 class PumpSides(IntEnum): BOTH = 0 LEFT = 1 RIGHT = 2 class MediaType(IntEnum): PICTURE = 0 VIDEO = 1 AUDIO = 2 # --------------------------------------------------------------------------- # Dates # --------------------------------------------------------------------------- # Older app versions wrote localized AM/PM markers, and DateTypeAdapter falls # back to rewriting them. Order matches the app. _MERIDIEM_MARKERS = ( ("a.m.", "AM"), ("p.m.", "PM"), ("vorm.", "AM"), ("nachm.", "PM"), ("午前", "AM"), ("午後", "PM"), ("上午", "AM"), ("下午", "PM"), ("a. m.", "AM"), ("p. m.", "PM"), ("fm", "AM"), ("em", "PM"), ("오전", "AM"), ("오후", "PM"), ) def format_date(value: datetime) -> str: """Format a datetime as the app does: ``yyyy-MM-dd HH:mm:ss Z`` in GMT.""" if value.tzinfo is None: raise ValueError("datetime must be timezone aware") return value.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S +0000") def parse_date(value: str) -> datetime: """Parse any date form accepted by the app's DateTypeAdapter.""" try: return datetime.strptime(value, "%Y-%m-%d %H:%M:%S %z") except ValueError: pass normalized = value for marker, meridiem in _MERIDIEM_MARKERS: normalized = normalized.replace(marker, meridiem) return datetime.strptime(normalized, "%Y-%m-%d %I:%M:%S %p %z") def _now() -> datetime: return datetime.now(timezone.utc) def _raw_utc_offset(moment: datetime) -> int: """The app's ``timezone`` field: UTC offset in seconds, excluding DST.""" local = moment.astimezone() offset = local.utcoffset() or timedelta(0) dst = local.dst() or timedelta(0) return int((offset - dst).total_seconds()) # --------------------------------------------------------------------------- # Record encoding # --------------------------------------------------------------------------- def _json_default(value: Any) -> Any: if isinstance(value, datetime): return format_date(value) raise TypeError(f"cannot serialize {type(value).__name__}") def _gson_enums(value: Any) -> Any: # Gson writes @SerializedName enums as strings, so DiaperStatus.POOPY is "1". # IntEnum is a JSON number to the json module, so this has to run first. if isinstance(value, IntEnum): return str(int(value)) if isinstance(value, Mapping): return {key: _gson_enums(item) for key, item in value.items()} if isinstance(value, (list, tuple)): return [_gson_enums(item) for item in value] return value def encode_object(obj: Mapping[str, Any]) -> str: """Encode a record for the ``Transaction`` field of a transaction.""" if TYPE_FIELD not in obj: raise ValueError(f"record has no {TYPE_FIELD}") text = json.dumps(_gson_enums(obj), indent=2, ensure_ascii=False, default=_json_default) return base64.b64encode(text.encode("utf-8")).decode("ascii") def decode_object(encoded: str) -> dict[str, Any]: """Decode the ``Transaction`` field of a transaction into a record.""" return json.loads(base64.b64decode(encoded).decode("utf-8")) @dataclass(frozen=True) class Device: """A device on the account and the newest SyncID the server holds for it.""" uuid: str last_sync_id: int @classmethod def from_json(cls, data: Mapping[str, Any]) -> "Device": return cls(uuid=data["DeviceUUID"], last_sync_id=int(data["LastSyncID"])) @dataclass(frozen=True) class Transaction: """One entry of a device's transaction log.""" sync_id: int op: OpCode payload: str @classmethod def from_json(cls, data: Mapping[str, Any]) -> "Transaction": # OPCode is a string when written by Gson. Gson also reads numbers here, # so int() accepts both forms. return cls( sync_id=int(data["SyncID"]), op=OpCode(int(data["OPCode"])), payload=data["Transaction"], ) @classmethod def create(cls, sync_id: int, op: OpCode, obj: Mapping[str, Any]) -> "Transaction": return cls(sync_id=sync_id, op=op, payload=encode_object(obj)) def to_json(self) -> dict[str, Any]: return {"SyncID": self.sync_id, "OPCode": str(int(self.op)), "Transaction": self.payload} @property def object(self) -> dict[str, Any]: return decode_object(self.payload) @property def object_type(self) -> Optional[str]: return self.object.get(TYPE_FIELD) # --------------------------------------------------------------------------- # Measures. Units follow get*OnInternational() in the app's *Measure classes. # --------------------------------------------------------------------------- def measure(value: float, english: bool = False) -> dict[str, Any]: """Build a measure, in the unit given by ``english``. ======== ======== ======= kind metric english ======== ======== ======= volume ml fl oz weight kg lb length cm in temp °C °F ======== ======== ======= """ return {"englishMeasure": english, "value": float(value)} # --------------------------------------------------------------------------- # Record builders # --------------------------------------------------------------------------- def new_activity( type_name: str, baby: Optional[Mapping[str, Any]], time: Optional[datetime] = None, note: str = "", **fields: Any, ) -> dict[str, Any]: """Build a new activity record, with the defaults of the app's constructors. ``baby`` is the full baby record, nested without its own BCObjectType. Pass ``None`` only for ``Pump``, the one activity type that has no baby. Extra keyword arguments are written as record fields under the given name. """ now = _now() moment = time or now record: dict[str, Any] = { TYPE_FIELD: type_name, "note": note, "pictureLoaded": True, "pictureNote": [], "time": moment, "timezone": _raw_utc_offset(moment), "newFlage": True, "objectID": str(uuid.uuid4()), "timestamp": now, } if baby is not None: record["baby"] = {key: value for key, value in baby.items() if key != TYPE_FIELD} record.update(fields) return record def diaper( baby: Mapping[str, Any], status: DiaperStatus, amount: DiaperAmount = DiaperAmount.NOSET, **kwargs: Any, ) -> dict[str, Any]: """Build a diaper record. ``amount`` defaults to NOSET because the app's statistics count WET with amount NONE as a dry diaper. """ return new_activity("Diaper", baby, status=status, amount=amount, **kwargs) def sleep(baby: Mapping[str, Any], minutes: int, **kwargs: Any) -> dict[str, Any]: return new_activity("Sleep", baby, duration=minutes, **kwargs) def formula(baby: Mapping[str, Any], amount: float, english: bool = False, **kwargs: Any) -> dict[str, Any]: return new_activity("Formula", baby, amount=measure(amount, english), **kwargs) def pumped(baby: Mapping[str, Any], amount: float, english: bool = False, **kwargs: Any) -> dict[str, Any]: """A feed of expressed milk. ``Pump`` is the separate pumping session record.""" return new_activity("Pumped", baby, amount=measure(amount, english), **kwargs) def nursing( baby: Mapping[str, Any], left_minutes: int = 0, right_minutes: int = 0, both_minutes: int = 0, finish_side: NursingFinishSide = NursingFinishSide.NOT_SET, **kwargs: Any, ) -> dict[str, Any]: return new_activity( "Nursing", baby, leftDuration=left_minutes, rightDuration=right_minutes, bothDuration=both_minutes, finishSide=finish_side, **kwargs, ) def temperature(baby: Mapping[str, Any], value: float, english: bool = False, **kwargs: Any) -> dict[str, Any]: return new_activity("Temperature", baby, temperature=measure(value, english), **kwargs) def growth( baby: Mapping[str, Any], weight: Optional[float] = None, length: Optional[float] = None, head: Optional[float] = None, english: bool = False, **kwargs: Any, ) -> dict[str, Any]: fields: dict[str, Any] = {"deleted": False, "queryIndex": 0} if weight is not None: fields["weight"] = measure(weight, english) if length is not None: fields["length"] = measure(length, english) if head is not None: fields["head"] = measure(head, english) return new_activity("Growth", baby, **fields, **kwargs) def bath(baby: Mapping[str, Any], **kwargs: Any) -> dict[str, Any]: return new_activity("Bath", baby, **kwargs) def joy(baby: Mapping[str, Any], **kwargs: Any) -> dict[str, Any]: return new_activity("Joy", baby, **kwargs) def journal(baby: Mapping[str, Any], note: str, **kwargs: Any) -> dict[str, Any]: return new_activity("Journal", baby, note=note, **kwargs)